diff --git a/.gitattributes b/.gitattributes
index 1b2aae1..ffc9537 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,6 @@
*.iss linguist-vendored=true
*.isl linguist-vendored=true
*.py linguist-vendored=true
+
+# Preserve the reviewed vendor artifact byte-for-byte on Windows and Unix.
+src/Web/vendor/ba-click-fx.iife.js -text
diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml
index 39894e6..bf7cb9d 100644
--- a/.github/workflows/pr-build-check.yml
+++ b/.github/workflows/pr-build-check.yml
@@ -20,6 +20,15 @@ jobs:
with:
dotnet-version: '8.0.x'
+ - name: Setup Node.js 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Run Web tests
+ shell: pwsh
+ run: node --test tests/Web/fx-adapter.test.mjs tests/Web/vendor-contract.test.mjs
+
- name: Run tests
shell: pwsh
run: dotnet test BASpark.sln -c Release --verbosity normal
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index aa5a75b..da66f7e 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -23,6 +23,19 @@ jobs:
with:
dotnet-version: '8.0.x'
+ - name: Setup Node.js 20
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Run Web tests
+ shell: pwsh
+ run: node --test tests/Web/fx-adapter.test.mjs tests/Web/vendor-contract.test.mjs
+
+ - name: Run .NET tests
+ shell: pwsh
+ run: dotnet test BASpark.sln -c Release --verbosity normal
+
- name: Build Packaged Files
shell: pwsh
run: |
@@ -61,4 +74,4 @@ jobs:
draft: false
prerelease: false
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/pages/guide.md b/pages/guide.md
index 112bc09..1ebfdea 100644
--- a/pages/guide.md
+++ b/pages/guide.md
@@ -52,10 +52,10 @@ In the "Visual Performance" sub-settings menu, you can adjust the following para
* **Global Opacity**: Adjusts the overall visual intensity and transparency of the effects.
* **Animation Speed**: Adjusts the overall playback speed of the effects.
* **Speed Synchronization**: Enabled by default. Unchecking this allows you to independently adjust "Click Speed" and "Trail Speed".
-* **Trail Refresh Rate**: Supports up to 240Hz. Higher values offer smoother paths but consume relatively more system resources.
+* **Input Sampling Rate Limit**: Accepts `0` (unlimited) or `1`–`1000Hz`, with a default of `40Hz`. Lower rates retain fewer pointer samples and create more angular paths; this setting is independent of animation speed and rendering frame rate.
### Theme Colors
-Click the **"Change Color"** button to bring up the color palette and customize the core color of the particles.
+Click the **"Change Color"** button to bring up the color palette and customize the effect theme. BASpark uses the recommended relative OKLCH mapping so hue, chroma, and perceptual lightness are adjusted before HDR and Bloom processing.
### Reset to Default
Click the **"Restore Default Settings"** button to selectively reset items to their default values.
@@ -119,4 +119,4 @@ No need to visit the website manually. In the **"About"** page of the software,
* **Auto Comparison**: The system automatically detects version discrepancies between your local build and the server.
* **One-Click Upgrade**: Provides automatic pop-up notifications containing update changelogs when a new version is detected.
-*Note: The automatic update feature requires an internet connection. Data fetching might fail under strict firewall environments.*
\ No newline at end of file
+*Note: The automatic update feature requires an internet connection. Data fetching might fail under strict firewall environments.*
diff --git a/pages/index.md b/pages/index.md
index 9233f87..d9e93a5 100644
--- a/pages/index.md
+++ b/pages/index.md
@@ -27,9 +27,9 @@ features:
- title: 🧠 Smart Environment Filter
details: Context-aware detection that auto-pauses in full-screen games/videos. Supports customizable Blacklist and Whitelist modes.
- title: 🎨 Precise Visual Tuning
- details: Fully customize scale ratio, opacity, animation speed, and trail refresh rates (up to 240Hz) via sliders or precise inputs.
+ details: Fully customize scale, opacity, animation speed, theme color, and input sampling rates from unlimited through 1000Hz.
- title: 🖥️ Multi-Screen Management
details: Intelligent monitor hardware ID memory. Independently toggle effects for each screen with dynamic topology refresh.
- title: 🌐 Global Optimization
details: Built-in network source selection (Mainland China / Global) ensures stable cloud notices and one-click auto-updates worldwide.
----
\ No newline at end of file
+---
diff --git a/src/AssemblyInfo.cs b/src/AssemblyInfo.cs
index cc29e7f..4b843c8 100644
--- a/src/AssemblyInfo.cs
+++ b/src/AssemblyInfo.cs
@@ -1,5 +1,7 @@
+using System.Runtime.CompilerServices;
using System.Windows;
+[assembly: InternalsVisibleTo("BASpark.Tests")]
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
diff --git a/src/BASpark.csproj b/src/BASpark.csproj
index 7152faf..e18954b 100644
--- a/src/BASpark.csproj
+++ b/src/BASpark.csproj
@@ -34,7 +34,21 @@
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/src/ConfigManager.cs b/src/ConfigManager.cs
index 8e0ded2..11353b8 100644
--- a/src/ConfigManager.cs
+++ b/src/ConfigManager.cs
@@ -15,7 +15,7 @@ public enum VisualAppearanceResetFlags
EffectScale = 1 << 0,
EffectOpacity = 1 << 1,
UnifiedAnimationSpeed = 1 << 2,
- TrailRefreshRate = 1 << 3,
+ InputSamplingRate = 1 << 3,
ParticleColor = 1 << 4,
TrailAnimationSpeed = 1 << 5,
ClickAnimationSpeed = 1 << 6
@@ -61,6 +61,9 @@ public class ScreenSelectionState
public static class ConfigManager
{
private const string RegPath = @"Software\BASpark";
+ public const int DefaultInputSamplingRate = 40;
+ public const int MinimumInputSamplingRate = 0;
+ public const int MaximumInputSamplingRate = 1000;
public static string ParticleColor { get; set; } = "45,175,255";
public static bool IsEffectEnabled { get; set; } = true;
@@ -79,7 +82,7 @@ public static class ConfigManager
public static bool ApplyCurveDraw { get; set; } = false;
public static double TrailAnimationSpeed { get; set; } = 1.0;
public static double ClickAnimationSpeed { get; set; } = 1.0;
- public static int TrailRefreshRate { get; set; } = 40;
+ public static int InputSamplingRate { get; set; } = DefaultInputSamplingRate;
public static bool EnableEnvironmentFilter { get; set; } = false;
public static bool HideInFullscreen { get; set; } = true;
public static bool ShowEffectOnDesktop { get; set; } = true;
@@ -141,7 +144,13 @@ public static void Load()
ApplyCurveDraw = Convert.ToBoolean(key.GetValue("ApplyCurveDraw", false));
TrailAnimationSpeed = Math.Clamp(Convert.ToDouble(key.GetValue("TrailAnimationSpeed", EffectSpeed), CultureInfo.InvariantCulture), 0.2, 3.0);
ClickAnimationSpeed = Math.Clamp(Convert.ToDouble(key.GetValue("ClickAnimationSpeed", EffectSpeed), CultureInfo.InvariantCulture), 0.2, 3.0);
- TrailRefreshRate = Math.Clamp(Convert.ToInt32(key.GetValue("TrailRefreshRate", 40), CultureInfo.InvariantCulture), 10, 240);
+ object legacyInputSamplingRate = key.GetValue(
+ "TrailRefreshRate",
+ DefaultInputSamplingRate) ?? DefaultInputSamplingRate;
+ InputSamplingRate = NormalizeInputSamplingRate(
+ Convert.ToInt32(
+ key.GetValue("InputSamplingRate", legacyInputSamplingRate),
+ CultureInfo.InvariantCulture));
EnableEnvironmentFilter = Convert.ToBoolean(key.GetValue("EnableEnvironmentFilter", false));
HideInFullscreen = Convert.ToBoolean(key.GetValue("HideInFullscreen", true));
ShowEffectOnDesktop = Convert.ToBoolean(key.GetValue("ShowEffectOnDesktop", true));
@@ -262,6 +271,14 @@ public static NetworkRegionOption ParseNetworkRegion(string? raw)
return NetworkRegionOption.Auto;
}
+ public static int NormalizeInputSamplingRate(int rateHz)
+ {
+ return Math.Clamp(
+ rateHz,
+ MinimumInputSamplingRate,
+ MaximumInputSamplingRate);
+ }
+
public static void GetAnimationSpeedsForOverlay(out double trailSpeed, out double clickSpeed)
{
if (UseLinkedAnimationSpeed)
@@ -340,9 +357,9 @@ public static void ApplyVisualAppearanceDefaults(VisualAppearanceResetFlags flag
Save("ClickAnimationSpeed", 1.0);
}
- if (flags.HasFlag(VisualAppearanceResetFlags.TrailRefreshRate))
+ if (flags.HasFlag(VisualAppearanceResetFlags.InputSamplingRate))
{
- Save("TrailRefreshRate", 40);
+ Save("InputSamplingRate", DefaultInputSamplingRate);
}
if (flags.HasFlag(VisualAppearanceResetFlags.ParticleColor))
@@ -576,7 +593,7 @@ public static void ResetAndClear()
ApplyCurveDraw = false;
TrailAnimationSpeed = 1.0;
ClickAnimationSpeed = 1.0;
- TrailRefreshRate = 40;
+ InputSamplingRate = DefaultInputSamplingRate;
EnableEnvironmentFilter = false;
HideInFullscreen = true;
ShowEffectOnDesktop = true;
diff --git a/src/ControlPanelWindow.xaml b/src/ControlPanelWindow.xaml
index f24ae04..64c3b7f 100644
--- a/src/ControlPanelWindow.xaml
+++ b/src/ControlPanelWindow.xaml
@@ -522,11 +522,11 @@
-
+
-
+
@@ -582,19 +582,21 @@
-
+
-
-
+
+
diff --git a/src/ControlPanelWindow.xaml.cs b/src/ControlPanelWindow.xaml.cs
index 11dba50..74534d0 100644
--- a/src/ControlPanelWindow.xaml.cs
+++ b/src/ControlPanelWindow.xaml.cs
@@ -588,7 +588,7 @@ private void LoadSettingsCore()
SliderSpeed.Value = ConfigManager.EffectSpeed;
SliderTrailAnimSpeed.Value = ConfigManager.TrailAnimationSpeed;
SliderClickAnimSpeed.Value = ConfigManager.ClickAnimationSpeed;
- SliderTrailRefresh.Value = ConfigManager.TrailRefreshRate;
+ SliderInputSamplingRate.Value = ConfigManager.InputSamplingRate;
UpdateAnimationSpeedPanelVisibility();
if (ConfigManager.ScrollbarVisibility == PanelScrollbarVisibility.Always)
@@ -1231,7 +1231,10 @@ private void RebuildVisualResetItems()
VisualResetItems.Add(new VisualResetItem(VisualAppearanceResetFlags.UnifiedAnimationSpeed, Localization.Get("VisualReset_UnifiedSpeed"), Localization.Get("VisualReset_UnifiedSpeed_Sub")));
VisualResetItems.Add(new VisualResetItem(VisualAppearanceResetFlags.TrailAnimationSpeed, Localization.Get("VisualReset_TrailSpeed"), Localization.Get("VisualReset_TrailSpeed_Sub")));
VisualResetItems.Add(new VisualResetItem(VisualAppearanceResetFlags.ClickAnimationSpeed, Localization.Get("VisualReset_ClickSpeed"), Localization.Get("VisualReset_ClickSpeed_Sub")));
- VisualResetItems.Add(new VisualResetItem(VisualAppearanceResetFlags.TrailRefreshRate, Localization.Get("VisualReset_TrailRefresh"), Localization.Get("VisualReset_TrailRefresh_Sub")));
+ VisualResetItems.Add(new VisualResetItem(
+ VisualAppearanceResetFlags.InputSamplingRate,
+ Localization.Get("VisualReset_InputSamplingRate"),
+ Localization.Get("VisualReset_InputSamplingRate_Sub")));
VisualResetItems.Add(new VisualResetItem(VisualAppearanceResetFlags.ParticleColor, Localization.Get("VisualReset_Color"), Localization.Get("VisualReset_Color_Sub")));
}
@@ -1311,13 +1314,13 @@ private void ConfirmVisualReset_Click(object sender, RoutedEventArgs e)
ConfigManager.ApplyVisualAppearanceDefaults(flags);
LoadSettings();
- int trailRefreshRate = (int)Math.Round(SliderTrailRefresh.Value);
+ int inputSamplingRate = (int)Math.Round(SliderInputSamplingRate.Value);
ConfigManager.GetAnimationSpeedsForOverlay(out double trailSp, out double clickSp);
double effectScale = Math.Round(SliderScale.Value, 2);
double effectOpacity = Math.Round(SliderOpacity.Value / 100.0, 2);
App.Overlay?.UpdateColor(ConfigManager.ParticleColor);
App.Overlay?.UpdateEffectSettings(effectScale, effectOpacity, trailSp, clickSp);
- App.Overlay?.UpdateTrailRefreshRate(trailRefreshRate);
+ App.Overlay?.UpdateInputSamplingRate(inputSamplingRate);
App.Overlay?.SetCurveDraw(CheckApplyCurveDraw.IsChecked ?? false);
VisualResetOverlay.Visibility = Visibility.Collapsed;
@@ -1378,7 +1381,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e)
effectSpeedForRegistry = clickAnimSpeed;
}
- int trailRefreshRate = (int)Math.Round(SliderTrailRefresh.Value);
+ int inputSamplingRate = (int)Math.Round(SliderInputSamplingRate.Value);
bool autoStartEnabled = CheckAutoStart.IsChecked ?? false;
bool startSilentEnabled = CheckStartSilent.IsChecked ?? false;
bool runAsAdminEnabled = CheckRunAsAdmin.IsChecked ?? false;
@@ -1409,7 +1412,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e)
ConfigManager.Save("EffectSpeed", effectSpeedForRegistry);
ConfigManager.Save("TrailAnimationSpeed", trailAnimSpeed);
ConfigManager.Save("ClickAnimationSpeed", clickAnimSpeed);
- ConfigManager.Save("TrailRefreshRate", trailRefreshRate);
+ ConfigManager.Save("InputSamplingRate", inputSamplingRate);
ConfigManager.Save("TotalClicks", ConfigManager.TotalClicks);
ConfigManager.Save("EnableAlwaysTrailEffect", CheckAlwaysTrailEffectSwitch.IsChecked ?? false);
var scrollbarVisibility = RadioScrollbarAlways.IsChecked == true
@@ -1483,7 +1486,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e)
App.Overlay?.UpdateColor(ConfigManager.ParticleColor);
GetUiAnimationSpeeds(out double overlayTrail, out double overlayClick);
App.Overlay?.UpdateEffectSettings(effectScale, effectOpacity, overlayTrail, overlayClick);
- App.Overlay?.UpdateTrailRefreshRate(trailRefreshRate);
+ App.Overlay?.UpdateInputSamplingRate(inputSamplingRate);
App.Overlay?.RefreshEnvironmentFilterState();
App.Overlay?.UpdateTouchMode(isTouchscreenEnabled);
App.Overlay?.UpdateScreenshotCompatibilityMode(screenshotCompatibilityEnabled);
diff --git a/src/MainWindow.xaml b/src/MainWindow.xaml
index e89f783..13de1b6 100644
--- a/src/MainWindow.xaml
+++ b/src/MainWindow.xaml
@@ -11,7 +11,7 @@
ResizeMode="NoResize"
Left="0" Top="0"
IsHitTestVisible="False">
-
+
-
\ No newline at end of file
+
diff --git a/src/MainWindow.xaml.cs b/src/MainWindow.xaml.cs
index 1ef778e..ecbe645 100644
--- a/src/MainWindow.xaml.cs
+++ b/src/MainWindow.xaml.cs
@@ -4,6 +4,7 @@
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
+using System.Text.Json;
using System.Windows;
using System.Windows.Forms;
using System.Windows.Interop;
@@ -80,11 +81,20 @@ private struct RECT
private bool? _lastReportedAlwaysTrail;
private const string InputModeMouse = "mouse";
private const string InputModeTouch = "touch";
+ private const string PrimaryRendererResourcePath = "Web/index.html";
+ private const string LegacyRendererResourcePath = "Web/index.legacy.html";
+ private const string RendererVendorResourcePath = "Web/vendor/ba-click-fx.iife.js";
+ private const string RendererAdapterResourcePath = "Web/fx-adapter.js";
private System.Windows.Threading.DispatcherTimer? _topmostTimer;
+ private System.Windows.Threading.DispatcherTimer? _rendererReadyTimeoutTimer;
+ private EventHandler? _navigationStartingHandler;
private EventHandler? _navigationCompletedHandler;
private EventHandler? _processFailedHandler;
+ private EventHandler? _webMessageReceivedHandler;
private CoreWebView2? _coreWebView;
+ private ulong? _currentNavigationId;
+ private string? _rendererGeneration;
private WinEventDelegate? _winEventDelegate;
private IntPtr _winEventHook = IntPtr.Zero;
private long _lastEnsureTopmostTicks;
@@ -94,6 +104,13 @@ private struct RECT
private bool _hiddenForExternalScreenshotCapture;
private bool _hiddenByEnvironmentSuppression;
private bool _overlayRuntimePaused;
+ private bool _rendererReady;
+ private bool _usingLegacyRenderer;
+ private int _inputSamplingRate = ConfigManager.DefaultInputSamplingRate;
+ private long _lastLegacyMoveTicks;
+ private bool _legacyFallbackAttempted;
+ private bool _processRecoveryPending;
+ private readonly WebViewUnresponsiveTracker _unresponsiveTracker = new();
private delegate void WinEventDelegate(
IntPtr hWinEventHook,
@@ -112,7 +129,7 @@ public MainWindow(Screen screen)
InitializeComponent();
webView.DefaultBackgroundColor = System.Drawing.Color.Transparent;
- UpdateTrailRefreshRate(ConfigManager.TrailRefreshRate);
+ UpdateInputSamplingRate(ConfigManager.InputSamplingRate);
_ = InitWebView();
}
@@ -195,7 +212,9 @@ private void SafeEnsureTopmost()
public void UpdateColor(string color)
{
- ExecuteScript($"if(window.updateColor) window.updateColor('{color}');");
+ // JSON serialization keeps registry-backed color text from becoming executable script.
+ string colorJson = JsonSerializer.Serialize(color);
+ ExecuteScript($"if(window.updateColor) window.updateColor({colorJson});");
}
public void UpdateEffectSettings(double scale, double opacity, double trailSpeed, double clickSpeed)
@@ -208,9 +227,19 @@ public void UpdateEffectSettings(double scale, double opacity, double trailSpeed
ExecuteScript($"if(window.updateEffectSettings) window.updateEffectSettings({scaleStr}, {opacityStr}, {trailStr}, {clickStr});");
}
- public void UpdateTrailRefreshRate(int hz)
+ public void UpdateInputSamplingRate(int rateHz)
{
- _ = hz;
+ _inputSamplingRate = ConfigManager.NormalizeInputSamplingRate(rateHz);
+ _lastLegacyMoveTicks = 0;
+ ExecuteScript(BuildInputSamplingRateScript(_inputSamplingRate));
+ }
+
+ internal static string BuildInputSamplingRateScript(int rateHz)
+ {
+ int normalizedRate = ConfigManager.NormalizeInputSamplingRate(rateHz);
+ return
+ "if(window.updateInputSamplingRate) " +
+ $"window.updateInputSamplingRate({normalizedRate.ToString(CultureInfo.InvariantCulture)});";
}
public void SetCurveDraw(bool enabled)
@@ -392,7 +421,10 @@ private async System.Threading.Tasks.Task InitWebView()
try
{
var env = await WebView2EnvironmentHolder.GetOrCreateAsync().ConfigureAwait(true);
- if (_isClosing) return;
+ if (_isClosing)
+ {
+ return;
+ }
if (webView.CoreWebView2 == null)
{
@@ -410,55 +442,359 @@ private async System.Threading.Tasks.Task InitWebView()
}
}
- if (_isClosing || !TryGetCoreWebView2(out CoreWebView2? coreWebView)) return;
+ if (_isClosing || !TryGetCoreWebView2(out CoreWebView2? coreWebView))
+ {
+ return;
+ }
+ DetachCoreWebViewEvents();
_coreWebView = coreWebView;
coreWebView.Settings.IsZoomControlEnabled = false;
coreWebView.Settings.AreDefaultContextMenusEnabled = false;
coreWebView.Settings.IsStatusBarEnabled = false;
- if (_processFailedHandler == null)
+ _processFailedHandler = OnWebViewProcessFailed;
+ _navigationStartingHandler = OnNavigationStarting;
+ _navigationCompletedHandler = OnNavigationCompleted;
+ _webMessageReceivedHandler = OnWebMessageReceived;
+ coreWebView.ProcessFailed += _processFailedHandler;
+ coreWebView.NavigationStarting += _navigationStartingHandler;
+ coreWebView.NavigationCompleted += _navigationCompletedHandler;
+ coreWebView.WebMessageReceived += _webMessageReceivedHandler;
+
+ NavigateCurrentRenderer(coreWebView);
+ }
+ catch (Exception ex) when (IsExpectedWebViewShutdownException(ex))
+ {
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show(Localization.Format("WebView2_InitFailed", ex.Message));
+ }
+ }
+
+ private void NavigateCurrentRenderer(CoreWebView2 coreWebView)
+ {
+ StopRendererReadyTimeout();
+ _rendererReady = _usingLegacyRenderer;
+
+ if (_usingLegacyRenderer)
+ {
+ _rendererGeneration = null;
+ string legacyHtml = ReadResourceText(LegacyRendererResourcePath);
+ NavigateHtml(coreWebView, legacyHtml);
+ return;
+ }
+
+ _rendererGeneration = null;
+ try
+ {
+ string rendererGeneration = Guid.NewGuid().ToString("N");
+ string rendererHtml = BuildPrimaryRendererHtml(rendererGeneration);
+ _rendererGeneration = rendererGeneration;
+ NavigateHtml(coreWebView, rendererHtml);
+ }
+ catch (Exception ex) when (!IsExpectedWebViewShutdownException(ex))
+ {
+ AppLogger.Error(
+ $"Failed to prepare BA click renderer on '{_screenDeviceName}'.",
+ ex);
+ FallbackToLegacyRenderer("primary renderer resource preparation failed");
+ return;
+ }
+
+ // A renderer that cannot announce readiness must not leave an invisible overlay active.
+ if (!_rendererReady)
+ {
+ StartRendererReadyTimeout();
+ }
+ }
+
+ private static string BuildPrimaryRendererHtml(string rendererGeneration)
+ {
+ string generationJson = JsonSerializer.Serialize(rendererGeneration);
+ string adapterScript =
+ $"window.__basparkRendererGeneration = {generationJson};\n" +
+ ReadResourceText(RendererAdapterResourcePath);
+
+ return WebRendererDocumentBuilder.Build(
+ ReadResourceText(PrimaryRendererResourcePath),
+ ReadResourceText(RendererVendorResourcePath),
+ adapterScript);
+ }
+
+ private static string ReadResourceText(string resourcePath)
+ {
+ string assemblyName = typeof(MainWindow).Assembly.GetName().Name ?? "BASpark";
+ // Explicit component lookup also works when a diagnostic host references BASpark.
+ var resourceUri = new Uri(
+ $"pack://application:,,,/{assemblyName};component/{resourcePath}",
+ UriKind.Absolute);
+ var streamInfo = System.Windows.Application.GetResourceStream(resourceUri);
+ if (streamInfo == null)
+ {
+ throw new InvalidOperationException($"Embedded renderer resource '{resourcePath}' was not found.");
+ }
+
+ using var reader = new System.IO.StreamReader(streamInfo.Stream);
+ return reader.ReadToEnd();
+ }
+
+ private void NavigateHtml(CoreWebView2 coreWebView, string html)
+ {
+ // NavigationStarting assigns the authoritative ID before any matching completion event.
+ _currentNavigationId = null;
+ coreWebView.NavigateToString(html);
+ }
+
+ private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
+ {
+ if (_isClosing || !ReferenceEquals(sender, _coreWebView))
+ {
+ return;
+ }
+
+ _currentNavigationId = e.NavigationId;
+ }
+
+ private void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
+ {
+ if (_isClosing ||
+ !ReferenceEquals(sender, _coreWebView) ||
+ _currentNavigationId != e.NavigationId)
+ {
+ return;
+ }
+
+ if (!e.IsSuccess)
+ {
+ if (!_usingLegacyRenderer)
{
- _processFailedHandler = OnWebViewProcessFailed;
- coreWebView.ProcessFailed += _processFailedHandler;
+ FallbackToLegacyRenderer($"navigation failed: {e.WebErrorStatus}");
}
+ else if (e.WebErrorStatus != CoreWebView2WebErrorStatus.OperationCanceled)
+ {
+ AppLogger.Error(
+ $"Legacy renderer navigation failed on '{_screenDeviceName}': {e.WebErrorStatus}.");
+ }
+ return;
+ }
- if (_navigationCompletedHandler != null)
+ // Navigation creates a new JS global object, so every page needs the complete host state.
+ _lastReportedInputMode = null;
+ _lastReportedAlwaysTrail = null;
+ UpdateColor(ConfigManager.ParticleColor);
+ ConfigManager.GetAnimationSpeedsForOverlay(out double trailSp, out double clickSp);
+ UpdateEffectSettings(ConfigManager.EffectScale, ConfigManager.EffectOpacity, trailSp, clickSp);
+ UpdateInputSamplingRate(ConfigManager.InputSamplingRate);
+ SyncInputContext(InputModeMouse);
+ if (_overlayRuntimePaused)
+ {
+ ExecuteScript("if(window.setRenderingPaused) window.setRenderingPaused(true);");
+ _ = TrySuspendWebViewAsync();
+ }
+ }
+
+ private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
+ {
+ if (_isClosing || _usingLegacyRenderer || !ReferenceEquals(sender, _coreWebView))
+ {
+ return;
+ }
+
+ string messageJson;
+ try
+ {
+ messageJson = e.TryGetWebMessageAsString();
+ }
+ catch (ArgumentException)
+ {
+ // Accept object messages defensively while the documented adapter contract remains JSON text.
+ messageJson = e.WebMessageAsJson;
+ }
+
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(messageJson);
+ JsonElement root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return;
+ }
+ if (!string.Equals(GetJsonString(root, "source"), "baspark-fx", StringComparison.Ordinal))
+ {
+ return;
+ }
+ if (!string.Equals(
+ GetJsonString(root, "generation"),
+ _rendererGeneration,
+ StringComparison.Ordinal))
{
- try { coreWebView.NavigationCompleted -= _navigationCompletedHandler; } catch { /* best-effort */ }
+ return;
}
- var streamInfo = System.Windows.Application.GetResourceStream(new Uri("pack://application:,,,/Web/index.html"));
- if (streamInfo != null)
+ string? type = GetJsonString(root, "type");
+ string requestedEffectBackend =
+ GetJsonString(root, "requestedEffectBackend") ?? "unknown";
+ string resolvedEffectBackend =
+ GetJsonString(root, "resolvedEffectBackend") ?? "unknown";
+ string requestedBloomBackend =
+ GetJsonString(root, "requestedBloomBackend") ?? "unknown";
+ string resolvedBloomBackend =
+ GetJsonString(root, "resolvedBloomBackend") ?? "unknown";
+ string requestedHostCompositing =
+ GetJsonString(root, "requestedHostCompositing") ?? "unknown";
+ string resolvedHostCompositing =
+ GetJsonString(root, "resolvedHostCompositing") ?? "unknown";
+ string hostCompositingSurface =
+ GetJsonString(root, "hostCompositingSurface") ?? "unknown";
+ string? compositingWarning =
+ GetJsonString(root, "compositingWarning");
+ string backend = GetJsonString(root, "backend") ??
+ (resolvedEffectBackend == "webgl2"
+ ? resolvedEffectBackend
+ : resolvedBloomBackend);
+
+ if (string.Equals(type, "ready", StringComparison.Ordinal))
{
- using var reader = new System.IO.StreamReader(streamInfo.Stream);
- string htmlContent = reader.ReadToEnd();
- coreWebView.NavigateToString(htmlContent);
- _navigationCompletedHandler = (s, e) =>
+ bool firstReadyMessage = !_rendererReady;
+ _rendererReady = true;
+ _unresponsiveTracker.Reset();
+ StopRendererReadyTimeout();
+ if (firstReadyMessage)
{
- if (_isClosing) return;
-
- _lastReportedInputMode = null;
- _lastReportedAlwaysTrail = null;
- UpdateColor(ConfigManager.ParticleColor);
- ConfigManager.GetAnimationSpeedsForOverlay(out double trailSp, out double clickSp);
- UpdateEffectSettings(ConfigManager.EffectScale, ConfigManager.EffectOpacity, trailSp, clickSp);
- SyncInputContext(InputModeMouse);
- if (_overlayRuntimePaused)
- {
- ExecuteScript("if(window.setRenderingPaused) window.setRenderingPaused(true);");
- _ = TrySuspendWebViewAsync();
- }
- };
- coreWebView.NavigationCompleted += _navigationCompletedHandler;
+ AppLogger.Info(
+ $"BA click renderer ready on '{_screenDeviceName}' " +
+ $"(effective: {backend}, effect: {resolvedEffectBackend}, " +
+ $"bloom: {resolvedBloomBackend}, host: " +
+ $"{resolvedHostCompositing} on {hostCompositingSurface}" +
+ $"{FormatCompositingWarning(compositingWarning)}).");
+ }
+ return;
}
+
+ if (string.Equals(type, "backend", StringComparison.Ordinal))
+ {
+ AppLogger.Info(
+ $"BA click renderer backend on '{_screenDeviceName}': " +
+ $"effective {backend}; effect {resolvedEffectBackend} " +
+ $"(requested {requestedEffectBackend}); bloom {resolvedBloomBackend} " +
+ $"(requested {requestedBloomBackend}); host " +
+ $"{resolvedHostCompositing} on {hostCompositingSurface} " +
+ $"(requested {requestedHostCompositing})" +
+ $"{FormatCompositingWarning(compositingWarning)}.");
+ return;
+ }
+
+ if (string.Equals(type, "error", StringComparison.Ordinal))
+ {
+ string phase = GetJsonString(root, "phase") ?? "unknown";
+ string message = GetJsonString(root, "message") ?? "unspecified renderer error";
+ AppLogger.Error(
+ $"BA click renderer error on '{_screenDeviceName}' during '{phase}': {message}.");
+ FallbackToLegacyRenderer($"renderer error during '{phase}'");
+ }
+ }
+ catch (JsonException ex)
+ {
+ AppLogger.Warn(
+ $"Ignored malformed renderer message on '{_screenDeviceName}': {ex.Message}");
+ }
+ }
+
+ private static string? GetJsonString(JsonElement element, string propertyName)
+ {
+ if (!element.TryGetProperty(propertyName, out JsonElement value) ||
+ value.ValueKind != JsonValueKind.String)
+ {
+ return null;
+ }
+
+ return value.GetString();
+ }
+
+ private static string FormatCompositingWarning(string? warning)
+ {
+ return string.IsNullOrWhiteSpace(warning)
+ ? string.Empty
+ : $"; warning: {warning}";
+ }
+
+ private void StartRendererReadyTimeout()
+ {
+ StopRendererReadyTimeout();
+ _rendererReadyTimeoutTimer = new System.Windows.Threading.DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(2)
+ };
+ _rendererReadyTimeoutTimer.Tick += OnRendererReadyTimeout;
+ _rendererReadyTimeoutTimer.Start();
+ }
+
+ private void OnRendererReadyTimeout(object? sender, EventArgs e)
+ {
+ if (!ReferenceEquals(sender, _rendererReadyTimeoutTimer))
+ {
+ return;
+ }
+
+ StopRendererReadyTimeout();
+ if (_isClosing || _rendererReady || _usingLegacyRenderer)
+ {
+ return;
+ }
+
+ AppLogger.Warn(
+ $"BA click renderer ready timeout on '{_screenDeviceName}'; switching to legacy renderer.");
+ FallbackToLegacyRenderer("ready timeout");
+ }
+
+ private void StopRendererReadyTimeout()
+ {
+ if (_rendererReadyTimeoutTimer == null)
+ {
+ return;
+ }
+
+ _rendererReadyTimeoutTimer.Stop();
+ _rendererReadyTimeoutTimer.Tick -= OnRendererReadyTimeout;
+ _rendererReadyTimeoutTimer = null;
+ }
+
+ private void FallbackToLegacyRenderer(string reason)
+ {
+ if (_isClosing || _usingLegacyRenderer || _legacyFallbackAttempted)
+ {
+ return;
+ }
+
+ if (!TryGetCoreWebView2(out CoreWebView2? coreWebView))
+ {
+ return;
+ }
+
+ // The one-way fallback prevents a broken primary/legacy pair from navigating forever.
+ _legacyFallbackAttempted = true;
+ _usingLegacyRenderer = true;
+ _rendererReady = true;
+ _rendererGeneration = null;
+ StopRendererReadyTimeout();
+ AppLogger.Warn(
+ $"Falling back to legacy renderer on '{_screenDeviceName}' ({reason}).");
+
+ try
+ {
+ string legacyHtml = ReadResourceText(LegacyRendererResourcePath);
+ NavigateHtml(coreWebView, legacyHtml);
}
catch (Exception ex) when (IsExpectedWebViewShutdownException(ex))
{
}
- catch (Exception ex)
+ catch (Exception ex) when (!IsExpectedWebViewShutdownException(ex))
{
- System.Windows.MessageBox.Show(Localization.Format("WebView2_InitFailed", ex.Message));
+ AppLogger.Error(
+ $"Failed to load legacy renderer on '{_screenDeviceName}'.",
+ ex);
}
}
@@ -470,21 +806,174 @@ private static bool IsWebViewEnvironmentConflictException(Exception ex)
private void OnWebViewProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs e)
{
- if (_isClosing) return;
+ if (_isClosing ||
+ sender is not CoreWebView2 failedCoreWebView ||
+ !ReferenceEquals(failedCoreWebView, _coreWebView))
+ {
+ return;
+ }
+
+ CoreWebView2ProcessFailedKind failureKind = e.ProcessFailedKind;
+ WebViewProcessRecoveryAction recoveryAction =
+ WebViewProcessFailurePolicy.GetRecoveryAction(failureKind);
+ if (failureKind == CoreWebView2ProcessFailedKind.RenderProcessUnresponsive)
+ {
+ bool shouldRecover = _unresponsiveTracker.Register(
+ failedCoreWebView,
+ DateTime.UtcNow.Ticks);
+ if (!shouldRecover)
+ {
+ AppLogger.Warn(
+ $"WebView2 renderer unresponsive on '{_screenDeviceName}' " +
+ $"({_unresponsiveTracker.ConsecutiveReports}/3); waiting for runtime recovery.");
+ return;
+ }
+ recoveryAction = WebViewProcessRecoveryAction.RecreateWebViewControl;
+ }
+ if (recoveryAction == WebViewProcessRecoveryAction.None)
+ {
+ AppLogger.Warn(
+ $"WebView2 process event on '{_screenDeviceName}' ({failureKind}); runtime recovery is left intact.");
+ return;
+ }
+
+ if (_processRecoveryPending)
+ {
+ return;
+ }
+
+ _processRecoveryPending = true;
Dispatcher.BeginInvoke(new Action(() =>
{
- if (_isClosing) return;
- if (_coreWebView != null && _processFailedHandler != null)
+ try
+ {
+ if (_isClosing || !ReferenceEquals(failedCoreWebView, _coreWebView))
+ {
+ return;
+ }
+
+ AppLogger.Warn(
+ $"WebView2 process failed on '{_screenDeviceName}' ({failureKind}); recovering renderer.");
+ if (recoveryAction == WebViewProcessRecoveryAction.RecreateWebViewControl)
+ {
+ RecreateWebViewControl();
+ }
+ else
+ {
+ NavigateCurrentRenderer(failedCoreWebView);
+ }
+ }
+ catch (Exception ex) when (IsExpectedWebViewShutdownException(ex))
{
- try { _coreWebView.ProcessFailed -= _processFailedHandler; } catch { /* ignore: event unsubscribe is best-effort */ }
+ // Process-failure recovery races with shutdown and must never escape the UI dispatcher.
+ if (!_isClosing)
+ {
+ AppLogger.Warn(
+ $"WebView2 recovery was interrupted on '{_screenDeviceName}': {ex.Message}");
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error(
+ $"WebView2 recovery failed on '{_screenDeviceName}'.",
+ ex);
+ }
+ finally
+ {
+ _processRecoveryPending = false;
}
- _processFailedHandler = null;
- _coreWebView = null;
- _ = InitWebView();
}));
}
+ private void RecreateWebViewControl()
+ {
+ StopRendererReadyTimeout();
+ // The browser process is already disconnected; Dispose owns native event cleanup.
+ ClearCoreWebViewEventState();
+ _coreWebView = null;
+
+ Microsoft.Web.WebView2.Wpf.WebView2 oldWebView = webView;
+ webViewHost.Children.Remove(oldWebView);
+ UnregisterName("webView");
+ oldWebView.Dispose();
+
+ var replacementWebView = new Microsoft.Web.WebView2.Wpf.WebView2
+ {
+ Name = "webView",
+ DefaultBackgroundColor = System.Drawing.Color.Transparent
+ };
+
+ RegisterName("webView", replacementWebView);
+ webView = replacementWebView;
+ webViewHost.Children.Add(replacementWebView);
+ _ = InitWebView();
+ }
+
+ private void DetachCoreWebViewEvents()
+ {
+ CoreWebView2? coreWebView = _coreWebView;
+ if (coreWebView != null)
+ {
+ if (_navigationCompletedHandler != null)
+ {
+ try
+ {
+ coreWebView.NavigationCompleted -= _navigationCompletedHandler;
+ }
+ catch (Exception ex) when (IsExpectedWebViewShutdownException(ex))
+ {
+ }
+ }
+
+ if (_navigationStartingHandler != null)
+ {
+ try
+ {
+ coreWebView.NavigationStarting -= _navigationStartingHandler;
+ }
+ catch (Exception ex) when (IsExpectedWebViewShutdownException(ex))
+ {
+ }
+ }
+
+ if (_processFailedHandler != null)
+ {
+ try
+ {
+ coreWebView.ProcessFailed -= _processFailedHandler;
+ }
+ catch (Exception ex) when (IsExpectedWebViewShutdownException(ex))
+ {
+ }
+ }
+
+ if (_webMessageReceivedHandler != null)
+ {
+ try
+ {
+ coreWebView.WebMessageReceived -= _webMessageReceivedHandler;
+ }
+ catch (Exception ex) when (IsExpectedWebViewShutdownException(ex))
+ {
+ }
+ }
+ }
+
+ ClearCoreWebViewEventState();
+ }
+
+ private void ClearCoreWebViewEventState()
+ {
+ _navigationStartingHandler = null;
+ _navigationCompletedHandler = null;
+ _processFailedHandler = null;
+ _webMessageReceivedHandler = null;
+ _currentNavigationId = null;
+ _rendererGeneration = null;
+ _unresponsiveTracker.Reset();
+ }
+
private static bool IsCursorVisible()
{
CURSORINFO pci = new CURSORINFO();
@@ -599,6 +1088,7 @@ public void EmitDown(int x, int y)
public void EmitMove(int x, int y, bool touchLike)
{
+ if (_usingLegacyRenderer && !ShouldDispatchLegacyMove()) return;
if (!TryConvertScreenToOverlayPoint(x, y, out System.Windows.Point clientPoint)) return;
string inputMode = touchLike ? InputModeTouch : InputModeMouse;
string px = FormatCoordinate(clientPoint.X);
@@ -606,12 +1096,39 @@ public void EmitMove(int x, int y, bool touchLike)
ExecuteWithInputContext(inputMode, $"if(window.externalMove) window.externalMove({px}, {py});");
}
+ private bool ShouldDispatchLegacyMove()
+ {
+ // 旧渲染器没有输入采样 API,仅在回退路径保留等价的宿主限频。
+ if (_inputSamplingRate == 0)
+ {
+ return true;
+ }
+
+ long currentTicks = DateTime.UtcNow.Ticks;
+ long intervalTicks = TimeSpan.FromSeconds(1.0 / _inputSamplingRate).Ticks;
+ if (currentTicks - _lastLegacyMoveTicks < intervalTicks)
+ {
+ return false;
+ }
+
+ _lastLegacyMoveTicks = currentTicks;
+ return true;
+ }
+
public void EmitUp(bool touchLike)
{
string inputMode = touchLike ? InputModeTouch : InputModeMouse;
ExecuteWithInputContext(inputMode, "if(window.externalUp) window.externalUp();");
}
+ public void EmitCancel()
+ {
+ ExecuteScript(
+ "if(window.externalCancel){window.externalCancel();}" +
+ "else if(window.spark&&window.spark.clearEffects){window.spark.clearEffects();}" +
+ "else if(window.externalUp){window.externalUp();}");
+ }
+
private void UpdateOverlayBounds()
{
Rectangle bounds = GetScreenBounds();
@@ -672,6 +1189,7 @@ private bool TryConvertScreenToOverlayPoint(int screenX, int screenY, out System
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
_isClosing = true;
+ StopRendererReadyTimeout();
base.OnClosing(e);
}
@@ -685,32 +1203,8 @@ protected override void OnClosed(EventArgs e)
_topmostTimer = null;
}
- if (_coreWebView != null)
- {
- if (_navigationCompletedHandler != null)
- {
- try
- {
- _coreWebView.NavigationCompleted -= _navigationCompletedHandler;
- }
- catch (Exception ex) when (IsExpectedWebViewShutdownException(ex))
- {
- }
- _navigationCompletedHandler = null;
- }
-
- if (_processFailedHandler != null)
- {
- try
- {
- _coreWebView.ProcessFailed -= _processFailedHandler;
- }
- catch (Exception ex) when (IsExpectedWebViewShutdownException(ex))
- {
- }
- _processFailedHandler = null;
- }
- }
+ StopRendererReadyTimeout();
+ DetachCoreWebViewEvents();
_coreWebView = null;
if (_winEventHook != IntPtr.Zero)
diff --git a/src/OverlayManager.cs b/src/OverlayManager.cs
index d6f20a3..98871ce 100644
--- a/src/OverlayManager.cs
+++ b/src/OverlayManager.cs
@@ -82,9 +82,8 @@ private struct MONITORINFO
private readonly Dictionary _overlays = new(StringComparer.OrdinalIgnoreCase);
private IKeyboardMouseEvents? _globalHook;
private MainWindow? _activePointerOverlay;
- private long _lastMoveTicks;
+ private MainWindow? _lastTrailOverlay;
private long _lastClickTicks;
- private long _moveIntervalTicks = 250000;
private bool _isPrimaryPointerDown;
private bool _isTouchLikeInput;
private bool _isSuppressedByEnvironment;
@@ -124,7 +123,7 @@ public void Start()
{
RebuildWindows(forceRebuild: true);
SetupGlobalHooks();
- UpdateTrailRefreshRate(ConfigManager.TrailRefreshRate);
+ UpdateInputSamplingRate(ConfigManager.InputSamplingRate);
RefreshEnvironmentFilterState();
SystemEvents.DisplaySettingsChanged += HandleDisplaySettingsChanged;
SystemEvents.PowerModeChanged += HandlePowerModeChanged;
@@ -134,11 +133,12 @@ public void Start()
public void UpdateColor(string color) => ForEachOverlay(w => w.UpdateColor(color));
public void UpdateEffectSettings(double scale, double opacity, double trailSpeed, double clickSpeed) =>
ForEachOverlay(w => w.UpdateEffectSettings(scale, opacity, trailSpeed, clickSpeed));
- public void UpdateTrailRefreshRate(int hz)
+ public void UpdateInputSamplingRate(int rateHz)
{
- hz = Math.Clamp(hz, 10, 240);
- _moveIntervalTicks = TimeSpan.FromSeconds(1.0 / hz).Ticks;
- ForEachOverlay(w => w.UpdateTrailRefreshRate(hz));
+ int normalizedRate = ConfigManager.NormalizeInputSamplingRate(rateHz);
+ // ba-click-fx 使用未缩放的真实输入时间采样。宿主只负责转发,
+ // 避免 C# 与 WebView2 按同一频率连续丢点而让实际频率再次降低。
+ ForEachOverlay(w => w.UpdateInputSamplingRate(normalizedRate));
}
public void UpdateTouchMode(bool enabled) => ForEachOverlay(w => w.UpdateTouchMode(enabled));
public void UpdateScreenshotCompatibilityMode(bool enabled)
@@ -551,7 +551,19 @@ private void OnMouseDownExt(object? sender, MouseEventExtArgs e)
}
MainWindow? target = ResolveTargetOverlay(cursorX, cursorY);
- if (target == null) return;
+ if (target == null)
+ {
+ return;
+ }
+
+ if (ConfigManager.EnableAlwaysTrailEffect)
+ {
+ SwitchAlwaysTrailOverlay(target);
+ }
+ else
+ {
+ SwitchAlwaysTrailOverlay(null);
+ }
_isPrimaryPointerDown = true;
_isTouchLikeInput = !CursorIsVisible();
@@ -568,15 +580,22 @@ private void OnMouseDownExt(object? sender, MouseEventExtArgs e)
private void OnMouseMoveExt(object? sender, MouseEventExtArgs e)
{
// 拖尾仅在点击特效开启或常驻拖尾开启时渲染
- if (!ConfigManager.IsTrailEffectActive) return;
- if (!CanRenderEffects()) return;
+ if (!ConfigManager.IsTrailEffectActive)
+ {
+ SwitchAlwaysTrailOverlay(null);
+ return;
+ }
+ if (!CanRenderEffects())
+ {
+ return;
+ }
bool cursorVisible = CursorIsVisible();
- if (!cursorVisible && !_isPrimaryPointerDown) return;
-
- long currentTicks = DateTime.Now.Ticks;
- if (currentTicks - _lastMoveTicks < _moveIntervalTicks) return;
- _lastMoveTicks = currentTicks;
+ if (!cursorVisible && !_isPrimaryPointerDown)
+ {
+ SwitchAlwaysTrailOverlay(null);
+ return;
+ }
if (!TryGetPhysicalCursorPosition(out int cursorX, out int cursorY))
{
@@ -584,7 +603,12 @@ private void OnMouseMoveExt(object? sender, MouseEventExtArgs e)
cursorY = e.Y;
}
- var target = _activePointerOverlay ?? ResolveTargetOverlay(cursorX, cursorY);
+ MainWindow? target = _activePointerOverlay ?? ResolveTargetOverlay(cursorX, cursorY);
+ if (!_isPrimaryPointerDown)
+ {
+ SwitchAlwaysTrailOverlay(
+ ConfigManager.EnableAlwaysTrailEffect ? target : null);
+ }
target?.EmitMove(cursorX, cursorY, _isTouchLikeInput || !cursorVisible);
}
@@ -604,9 +628,7 @@ private void OnMouseUpExt(object? sender, MouseEventExtArgs e)
}
_activePointerOverlay?.EmitUp(_isTouchLikeInput);
- _isPrimaryPointerDown = false;
- _isTouchLikeInput = false;
- _activePointerOverlay = null;
+ ResetPrimaryPointerState();
}
///
@@ -638,9 +660,16 @@ private bool CanRenderEffects(bool skipSuppressionCheck = false)
private void ReleasePointerStateSilent()
{
- _isPrimaryPointerDown = false;
- _isTouchLikeInput = false;
- _activePointerOverlay = null;
+ MainWindow? activePointerOverlay = _activePointerOverlay;
+ activePointerOverlay?.EmitCancel();
+ if (_lastTrailOverlay != null &&
+ !ReferenceEquals(_lastTrailOverlay, activePointerOverlay))
+ {
+ _lastTrailOverlay.EmitCancel();
+ }
+
+ ResetPrimaryPointerState();
+ _lastTrailOverlay = null;
}
private void ReleasePointerState()
@@ -652,7 +681,26 @@ private void ReleasePointerState()
}
_activePointerOverlay?.EmitUp(_isTouchLikeInput);
- ReleasePointerStateSilent();
+ ResetPrimaryPointerState();
+ }
+
+ private void ResetPrimaryPointerState()
+ {
+ _isPrimaryPointerDown = false;
+ _isTouchLikeInput = false;
+ _activePointerOverlay = null;
+ }
+
+ private void SwitchAlwaysTrailOverlay(MainWindow? target)
+ {
+ if (ReferenceEquals(_lastTrailOverlay, target))
+ {
+ return;
+ }
+
+ // The old renderer must forget its last point before coordinates switch to another screen.
+ _lastTrailOverlay?.EmitCancel();
+ _lastTrailOverlay = target;
}
private MainWindow? ResolveTargetOverlay(int x, int y)
@@ -681,6 +729,14 @@ private void RebuildWindows(bool forceRebuild)
.Select(item => item.Screen)
.ToDictionary(screen => screen.DeviceName, screen => screen, StringComparer.OrdinalIgnoreCase);
+ bool topologyChanged = forceRebuild ||
+ _overlays.Keys.Except(targetScreens.Keys, StringComparer.OrdinalIgnoreCase).Any() ||
+ targetScreens.Keys.Except(_overlays.Keys, StringComparer.OrdinalIgnoreCase).Any();
+ if (topologyChanged)
+ {
+ ReleasePointerStateSilent();
+ }
+
if (forceRebuild)
{
CloseWindows();
@@ -719,7 +775,25 @@ private void CloseOverlay(string deviceName)
return;
}
- try { overlay.Close(); } catch (Exception ex) { AppLogger.Warn($"Failed to close overlay for '{deviceName}': {ex.Message}"); }
+ // Cancel before disposal because a closed WebView can no longer clear retained trail state.
+ overlay.EmitCancel();
+ if (ReferenceEquals(_activePointerOverlay, overlay))
+ {
+ ResetPrimaryPointerState();
+ }
+ if (ReferenceEquals(_lastTrailOverlay, overlay))
+ {
+ _lastTrailOverlay = null;
+ }
+
+ try
+ {
+ overlay.Close();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn($"Failed to close overlay for '{deviceName}': {ex.Message}");
+ }
_overlays.Remove(deviceName);
}
@@ -1014,7 +1088,7 @@ private void RecoverAfterSystemResume()
{
RebuildWindows(forceRebuild: true);
SetupGlobalHooks();
- UpdateTrailRefreshRate(ConfigManager.TrailRefreshRate);
+ UpdateInputSamplingRate(ConfigManager.InputSamplingRate);
RefreshEnvironmentFilterState();
}
diff --git a/src/Resources/Strings.en.resx b/src/Resources/Strings.en.resx
index b9c56b0..6145705 100644
--- a/src/Resources/Strings.en.resx
+++ b/src/Resources/Strings.en.resx
@@ -547,10 +547,10 @@ Download now?
Default 1.50 ×
-
- Trail refresh rate
+
+ Input sampling rate limit
-
+
Default 40 Hz
@@ -584,13 +584,13 @@ Download now?
Use same animation speed for trail and click
- When off, trail and click speeds are independent of trail refresh rate below.
+ When off, trail and click speeds are independent of the input sampling rate below.
Use curved trail lines
- Recommended for 60Hz and above
+ Recommended at input sampling rates of 60 Hz or higher
Global opacity
@@ -607,8 +607,11 @@ Download now?
Trail animation speed
-
- Trail refresh rate
+
+ Input sampling rate limit
+
+
+ 0 means unlimited; lower rates produce more angular trail paths.
WebView2 initialization failed: {0}
diff --git a/src/Resources/Strings.ja.resx b/src/Resources/Strings.ja.resx
index 99781ac..4418cbb 100644
--- a/src/Resources/Strings.ja.resx
+++ b/src/Resources/Strings.ja.resx
@@ -547,10 +547,10 @@
既定 1.50 ×
-
- トレイル更新率
+
+ 入力サンプリングレート上限
-
+
既定 40 Hz
@@ -584,13 +584,13 @@
トレイルとクリックで同じアニメ速度を使用
- オフにするとトレイルとクリックの速度を個別に設定できます(下の更新率とは別)。
+ オフにするとトレイルとクリックの速度を個別に設定できます(下の入力サンプリングレートとは別)。
曲線トレイルを使用
- 60Hz以上のリフレッシュレートでの使用を推奨します
+ 入力サンプリングレート 60 Hz 以上での使用を推奨します
全体の不透明度
@@ -607,8 +607,11 @@
トレイルのアニメ速度
-
- トレイル更新率
+
+ 入力サンプリングレート上限
+
+
+ 0 は無制限です。低い値ほど折れ線状のトレイルが強調されます。
WebView2 の初期化に失敗しました: {0}
diff --git a/src/Resources/Strings.resx b/src/Resources/Strings.resx
index 1d36971..66430a3 100644
--- a/src/Resources/Strings.resx
+++ b/src/Resources/Strings.resx
@@ -547,10 +547,10 @@
默认 1.50 ×
-
- 拖尾刷新率
+
+ 输入采样率上限
-
+
默认 40 Hz
@@ -584,13 +584,13 @@
拖尾与点击使用同一动画速度
- 关闭此项后可单独加快或减慢拖尾衰减与移动粒子,而不影响点击波纹与爆发粒子,与下方「拖尾刷新率」不同。
+ 关闭此项后可单独加快或减慢拖尾衰减与移动粒子,而不影响点击波纹与爆发粒子,与下方「输入采样率上限」不同。
采用曲线拖尾
- 建议在60Hz及以上刷新率开启
+ 输入采样率为 60 Hz 及以上时建议开启
全局不透明度
@@ -607,8 +607,11 @@
拖尾动画速度
-
- 拖尾刷新率
+
+ 输入采样率上限
+
+
+ 0 表示不限频;较低值会形成更明显的折线拖尾。
WebView2 初始化失败: {0}
diff --git a/src/UiLocalizer.cs b/src/UiLocalizer.cs
index 5d481c7..15222b2 100644
--- a/src/UiLocalizer.cs
+++ b/src/UiLocalizer.cs
@@ -98,7 +98,8 @@ public static void ApplyControlPanel(ControlPanelWindow w)
w.TxtAnimSpeed.Text = Localization.Get("Visual_AnimSpeed");
w.TxtTrailAnimSpeed.Text = Localization.Get("Visual_TrailAnimSpeed");
w.TxtClickAnimSpeed.Text = Localization.Get("Visual_ClickAnimSpeed");
- w.TxtTrailRefresh.Text = Localization.Get("Visual_TrailRefresh");
+ w.TxtInputSamplingRate.Text = Localization.Get("Visual_InputSamplingRate");
+ w.TxtInputSamplingRateHint.Text = Localization.Get("Visual_InputSamplingRateHint");
w.TxtEffectColor.Text = Localization.Get("Visual_Color");
w.BtnPickColor.Content = Localization.Get("Visual_ChangeColor");
diff --git a/src/Web/fx-adapter.js b/src/Web/fx-adapter.js
new file mode 100644
index 0000000..29c274c
--- /dev/null
+++ b/src/Web/fx-adapter.js
@@ -0,0 +1,767 @@
+(function ()
+{
+ 'use strict';
+
+ const POINTER_ID = 1;
+ const DEFAULT_COLOR = '#2dafff';
+ const DEFAULT_INPUT_SAMPLING_RATE = 40;
+ const HOST_GENERATION =
+ typeof window.__basparkRendererGeneration === 'string'
+ ? window.__basparkRendererGeneration
+ : '';
+ const DEFAULT_SETTINGS = Object.freeze(
+ {
+ scale: 1.5,
+ opacity: 1,
+ trailSpeed: 1,
+ clickSpeed: 1,
+ });
+ const DOM_CONTENT_LOADED_OPTIONS =
+ {
+ once: true,
+ };
+ const state =
+ {
+ fx: null,
+ initialized: false,
+ paused: false,
+ inputMode: 'mouse',
+ alwaysTrailEnabled: false,
+ effectiveAlwaysTrail: false,
+ activePointerKind: null,
+ color: DEFAULT_COLOR,
+ inputSamplingRate: DEFAULT_INPUT_SAMPLING_RATE,
+ settings:
+ {
+ ...DEFAULT_SETTINGS,
+ },
+ lastBoomX: -1,
+ lastBoomY: -1,
+ lastBoomTime: 0,
+ lastMoveX: -1,
+ lastMoveY: -1,
+ };
+
+ function clamp(value, minimum, maximum)
+ {
+ return Math.max(minimum, Math.min(maximum, value));
+ }
+
+ function errorMessage(error)
+ {
+ if (error instanceof Error)
+ {
+ return error.message;
+ }
+
+ return String(error);
+ }
+
+ function postHostMessage(type, detail = null)
+ {
+ const message =
+ {
+ source: 'baspark-fx',
+ generation: HOST_GENERATION,
+ type,
+ ...(detail || Object.create(null)),
+ };
+
+ try
+ {
+ if (
+ window.chrome &&
+ window.chrome.webview &&
+ typeof window.chrome.webview.postMessage === 'function'
+ )
+ {
+ // 宿主统一按 JSON 字符串解析,避免 WebView2 对象封送行为随版本变化。
+ window.chrome.webview.postMessage(JSON.stringify(message));
+ }
+ }
+ catch (error)
+ {
+ console.error('[BASpark FX] 无法向宿主发送消息:', error);
+ }
+ }
+
+ function reportError(phase, error)
+ {
+ const message = errorMessage(error);
+
+ console.error(`[BASpark FX] ${phase}:`, error);
+ postHostMessage(
+ 'error',
+ {
+ phase,
+ message,
+ });
+ }
+
+ function invokeFx(phase, action)
+ {
+ try
+ {
+ return action();
+ }
+ catch (error)
+ {
+ reportError(phase, error);
+ return false;
+ }
+ }
+
+ function resetInputCache()
+ {
+ state.activePointerKind = null;
+ state.lastBoomX = -1;
+ state.lastBoomY = -1;
+ state.lastBoomTime = 0;
+ state.lastMoveX = -1;
+ state.lastMoveY = -1;
+ }
+
+ function pointerType()
+ {
+ return state.inputMode === 'touch' ? 'touch' : 'mouse';
+ }
+
+ function toCanvasPoint(percentX, percentY)
+ {
+ if (!state.fx)
+ {
+ return null;
+ }
+
+ const normalizedX = Number(percentX);
+ const normalizedY = Number(percentY);
+
+ if (!Number.isFinite(normalizedX) || !Number.isFinite(normalizedY))
+ {
+ return null;
+ }
+
+ // C# 传入窗口内归一化坐标;公开 API 要求 Canvas 局部 CSS 像素。
+ return (
+ {
+ x: clamp(normalizedX, 0, 1) * state.fx.width,
+ y: clamp(normalizedY, 0, 1) * state.fx.height,
+ pointerId: POINTER_ID,
+ pointerType: pointerType(),
+ });
+ }
+
+ function cancelPointerImmediately()
+ {
+ const accepted = state.fx.pointerCancel(POINTER_ID);
+
+ // 宿主取消表示输入所有权切换;清除全部拖尾,避免上一所有者的残留。
+ state.fx.clearTrail();
+ state.activePointerKind = null;
+ return accepted;
+ }
+
+ function cancelActivePointer()
+ {
+ if (!state.fx || state.activePointerKind === null)
+ {
+ return false;
+ }
+
+ return cancelPointerImmediately();
+ }
+
+ function applyInputContext()
+ {
+ if (!state.fx)
+ {
+ return;
+ }
+
+ state.fx.updateConfig(
+ {
+ trailAlways: state.effectiveAlwaysTrail,
+ });
+ }
+
+ function applyColor()
+ {
+ if (!state.fx)
+ {
+ return;
+ }
+
+ state.fx.setThemeColor(state.color);
+ }
+
+ function applyEffectSettings()
+ {
+ if (!state.fx)
+ {
+ return;
+ }
+
+ state.fx.updateConfig(
+ {
+ // 旧引擎以 1.5 为默认尺寸,新引擎以 1 为默认尺寸。
+ scale: Math.max(0.01, state.settings.scale / 1.5),
+ opacity: state.settings.opacity,
+ trailTimeScale: state.settings.trailSpeed,
+ clickTimeScale: state.settings.clickSpeed,
+ });
+ }
+
+ function parseRgbColor(rgbString)
+ {
+ const channels = String(rgbString).split(',').map((channel) =>
+ {
+ return Number(channel.trim());
+ });
+
+ if (
+ channels.length !== 3 ||
+ channels.some((channel) => !Number.isFinite(channel))
+ )
+ {
+ return null;
+ }
+
+ return `#${channels.map((channel) =>
+ {
+ return Math.round(clamp(channel, 0, 255))
+ .toString(16)
+ .padStart(2, '0');
+ }).join('')}`;
+ }
+
+ function normalizeSpeed(value, fallback)
+ {
+ const numeric = Number(value);
+
+ if (!Number.isFinite(numeric))
+ {
+ return fallback;
+ }
+
+ return clamp(numeric, 0.2, 3);
+ }
+
+ function parseInputSamplingRate(value)
+ {
+ if (typeof value !== 'number')
+ {
+ return null;
+ }
+
+ const numeric = value;
+
+ if (
+ numeric !== 0 &&
+ (
+ !Number.isFinite(numeric) ||
+ numeric < 1 ||
+ numeric > 1000
+ )
+ )
+ {
+ return null;
+ }
+
+ return numeric;
+ }
+
+ window.externalBoom = function (percentX, percentY)
+ {
+ if (state.paused || !state.fx)
+ {
+ return false;
+ }
+
+ const numericX = Number(percentX);
+ const numericY = Number(percentY);
+ const now = Date.now();
+
+ if (
+ numericX === state.lastBoomX &&
+ numericY === state.lastBoomY &&
+ now - state.lastBoomTime < 25
+ )
+ {
+ return false;
+ }
+
+ const point = toCanvasPoint(numericX, numericY);
+
+ if (!point)
+ {
+ return false;
+ }
+
+ state.lastBoomX = numericX;
+ state.lastBoomY = numericY;
+ state.lastBoomTime = now;
+
+ return invokeFx('externalBoom', function ()
+ {
+ // 丢失抬起事件时先恢复指针状态,避免后续点击被单指针上限永久拒绝。
+ cancelActivePointer();
+ const accepted = state.fx.pointerDown(point);
+
+ if (accepted)
+ {
+ state.activePointerKind = 'press';
+ }
+
+ return accepted;
+ });
+ };
+
+ window.externalMove = function (percentX, percentY)
+ {
+ if (state.paused || !state.fx)
+ {
+ return false;
+ }
+
+ const numericX = Number(percentX);
+ const numericY = Number(percentY);
+
+ if (
+ numericX === state.lastMoveX &&
+ numericY === state.lastMoveY
+ )
+ {
+ return false;
+ }
+
+ const point = toCanvasPoint(numericX, numericY);
+
+ if (!point)
+ {
+ return false;
+ }
+
+ state.lastMoveX = numericX;
+ state.lastMoveY = numericY;
+
+ return invokeFx('externalMove', function ()
+ {
+ const accepted = state.fx.pointerMove(point);
+
+ if (
+ accepted &&
+ state.activePointerKind === null &&
+ state.effectiveAlwaysTrail
+ )
+ {
+ state.activePointerKind = 'hover';
+ }
+
+ return accepted;
+ });
+ };
+
+ window.externalUp = function ()
+ {
+ if (state.paused || !state.fx)
+ {
+ return false;
+ }
+
+ return invokeFx('externalUp', function ()
+ {
+ const accepted = state.fx.pointerUp(POINTER_ID);
+
+ if (accepted)
+ {
+ state.activePointerKind = null;
+ }
+
+ return accepted;
+ });
+ };
+
+ window.externalCancel = function ()
+ {
+ if (!state.fx)
+ {
+ resetInputCache();
+ return false;
+ }
+
+ return invokeFx('externalCancel', function ()
+ {
+ const accepted = cancelPointerImmediately();
+
+ resetInputCache();
+ return accepted;
+ });
+ };
+
+ window.setRenderingPaused = function (paused)
+ {
+ state.paused = Boolean(paused);
+
+ if (!state.fx)
+ {
+ return;
+ }
+
+ invokeFx('setRenderingPaused', function ()
+ {
+ if (state.paused)
+ {
+ resetInputCache();
+ state.fx.setPaused(
+ true,
+ {
+ clear: true,
+ });
+ return true;
+ }
+
+ state.fx.setPaused(false);
+ return true;
+ });
+ };
+
+ window.setInputContext = function (mode, alwaysTrailEnabled)
+ {
+ const nextMode = mode === 'touch' ? 'touch' : 'mouse';
+ const nextAlwaysTrailEnabled = Boolean(alwaysTrailEnabled);
+ const nextEffectiveAlwaysTrail =
+ nextMode === 'mouse' && nextAlwaysTrailEnabled;
+
+ if (
+ nextMode !== state.inputMode ||
+ (
+ !nextEffectiveAlwaysTrail &&
+ state.activePointerKind === 'hover'
+ )
+ )
+ {
+ invokeFx('setInputContext.cancel', function ()
+ {
+ return cancelActivePointer();
+ });
+ }
+
+ state.inputMode = nextMode;
+ state.alwaysTrailEnabled = nextAlwaysTrailEnabled;
+ state.effectiveAlwaysTrail = nextEffectiveAlwaysTrail;
+
+ invokeFx('setInputContext', function ()
+ {
+ applyInputContext();
+ return true;
+ });
+ };
+
+ window.updateColor = function (rgbString)
+ {
+ const color = parseRgbColor(rgbString);
+
+ if (!color)
+ {
+ // 配置损坏不代表渲染器失效,保留当前颜色即可继续运行。
+ console.warn('[BASpark FX] 忽略非法 RGB 颜色:', rgbString);
+ return false;
+ }
+
+ state.color = color;
+
+ return invokeFx('updateColor', function ()
+ {
+ applyColor();
+ return true;
+ });
+ };
+
+ window.updateInputSamplingRate = function (rateHz)
+ {
+ const rate = parseInputSamplingRate(rateHz);
+
+ if (rate === null)
+ {
+ console.warn('[BASpark FX] 忽略非法输入采样率:', rateHz);
+ return false;
+ }
+
+ state.inputSamplingRate = rate;
+
+ if (!state.fx)
+ {
+ return true;
+ }
+
+ return invokeFx('updateInputSamplingRate', function ()
+ {
+ return state.fx.setInputSamplingRate(rate);
+ });
+ };
+
+ window.updateEffectSettings = function (
+ scale,
+ opacity,
+ trailSpeed,
+ clickSpeed
+ )
+ {
+ const numericScale = Number(scale);
+ const numericOpacity = Number(opacity);
+ const safeTrailSpeed = normalizeSpeed(
+ trailSpeed,
+ DEFAULT_SETTINGS.trailSpeed,
+ );
+
+ state.settings =
+ {
+ scale: Number.isFinite(numericScale) && numericScale > 0
+ ? numericScale
+ : DEFAULT_SETTINGS.scale,
+ opacity: Number.isFinite(numericOpacity)
+ ? clamp(numericOpacity, 0.1, 1)
+ : DEFAULT_SETTINGS.opacity,
+ trailSpeed: safeTrailSpeed,
+ clickSpeed: normalizeSpeed(clickSpeed, safeTrailSpeed),
+ };
+
+ return invokeFx('updateEffectSettings', function ()
+ {
+ applyEffectSettings();
+ return true;
+ });
+ };
+
+ function readBackendState(detail = Object.create(null))
+ {
+ const config = state.fx.getConfig();
+ const requestedEffectBackend =
+ detail.requestedEffectBackend || config.effectBackend;
+ const resolvedEffectBackend =
+ detail.resolvedEffectBackend || config.resolvedEffectBackend;
+ const requestedBloomBackend =
+ detail.requestedBloomBackend || config.bloomBackend;
+ const resolvedBloomBackend =
+ detail.resolvedBloomBackend || config.resolvedBloomBackend;
+ const requestedHostCompositing =
+ detail.requestedHostCompositing ||
+ config.hostCompositing ||
+ 'unknown';
+ const resolvedHostCompositing =
+ detail.resolvedHostCompositing ||
+ config.resolvedHostCompositing ||
+ config.hostCompositing ||
+ 'unknown';
+ const hostCompositingSurface =
+ detail.hostCompositingSurface ||
+ config.hostCompositingSurface ||
+ 'unknown';
+ const compositingWarning =
+ detail.compositingWarning ?? config.compositingWarning ?? null;
+
+ return (
+ {
+ backend: resolvedEffectBackend === 'webgl2'
+ ? resolvedEffectBackend
+ : resolvedBloomBackend,
+ requestedEffectBackend,
+ resolvedEffectBackend,
+ requestedBloomBackend,
+ resolvedBloomBackend,
+ requestedHostCompositing,
+ resolvedHostCompositing,
+ hostCompositingSurface,
+ compositingWarning,
+ });
+ }
+
+ function handleBackendChange(event)
+ {
+ const backendState = readBackendState(
+ event.detail || Object.create(null),
+ );
+
+ postHostMessage(
+ 'backend',
+ backendState,
+ );
+
+ if (
+ backendState.resolvedEffectBackend !== 'webgl2' &&
+ backendState.resolvedBloomBackend === 'software'
+ )
+ {
+ // 全屏 Float32 软件 Bloom 对桌面覆盖层代价过高,GPU 不可用时改用原生辉光。
+ state.fx.updateConfig(
+ {
+ bloomBackend: 'native',
+ });
+ }
+ }
+
+ function initialize()
+ {
+ if (state.initialized)
+ {
+ return;
+ }
+
+ state.initialized = true;
+
+ try
+ {
+ if (
+ !window.BAClickFX ||
+ typeof window.BAClickFX.BAClickFX !== 'function'
+ )
+ {
+ throw new Error('ba-click-fx IIFE 未在适配器之前注入');
+ }
+
+ state.fx = new window.BAClickFX.BAClickFX(
+ {
+ inputSource: 'manual',
+ // WebView2 无法读取窗口后的桌面背景;使用 source-over,
+ // 再以浅色背景补偿和 Alpha 上限提高未知背景上的可见性。
+ effectBackend: 'webgl2',
+ bloomBackend: 'webgl2',
+ inputSamplingRate: state.inputSamplingRate,
+ outputCompositing: 'browser-overlay',
+ overlayAlphaPolicy: 'visual-max',
+ overlayColorCompensation: 'bright-core',
+ overlayAlphaLimit: 0.85,
+ hostCompositing: 'source-over',
+ hostCompositingSurface: 'transparent-window',
+ // 公共库为兼容旧像素默认 hue-only;BASpark 的取色器
+ // 明确使用上游推荐的相对 OKLCH 完整颜色映射。
+ themeColorMode: 'relative-oklch',
+ isolatedCompositing: false,
+ lightBackgroundContrastAlpha: 0,
+ maxDpr: 2,
+ });
+
+ const bloomBackendEventName =
+ window.BAClickFX.BLOOM_BACKEND_CHANGE_EVENT ||
+ 'baclickfxbackendchange';
+ const effectBackendEventName =
+ window.BAClickFX.EFFECT_BACKEND_CHANGE_EVENT ||
+ 'baclickfxeffectbackendchange';
+ const hostCompositingEventName =
+ window.BAClickFX.HOST_COMPOSITING_CHANGE_EVENT ||
+ 'baclickfxhostcompositingchange';
+
+ state.fx.canvas.addEventListener(
+ bloomBackendEventName,
+ handleBackendChange,
+ );
+ state.fx.canvas.addEventListener(
+ effectBackendEventName,
+ handleBackendChange,
+ );
+ state.fx.canvas.addEventListener(
+ hostCompositingEventName,
+ handleBackendChange,
+ );
+
+ applyInputContext();
+ applyColor();
+ applyEffectSettings();
+
+ if (state.paused)
+ {
+ state.fx.setPaused(
+ true,
+ {
+ clear: true,
+ });
+ }
+
+ postHostMessage(
+ 'ready',
+ readBackendState(),
+ );
+ }
+ catch (error)
+ {
+ reportError('initialize', error);
+ }
+ }
+
+ function installCompatibilityShims()
+ {
+ if (typeof Array.prototype.at !== 'function')
+ {
+ Object.defineProperty(
+ Array.prototype,
+ 'at',
+ {
+ configurable: true,
+ writable: true,
+ value: function (index)
+ {
+ const length = this.length >>> 0;
+ const integer = Math.trunc(Number(index) || 0);
+ const offset = integer < 0 ? length + integer : integer;
+
+ if (offset < 0 || offset >= length)
+ {
+ return undefined;
+ }
+
+ return this[offset];
+ },
+ });
+ }
+
+ if (typeof window.structuredClone !== 'function')
+ {
+ // 上游只克隆由数字、布尔值、数组和普通对象组成的配置快照。
+ window.structuredClone = function (value)
+ {
+ return JSON.parse(JSON.stringify(value));
+ };
+ }
+ }
+
+ window.addEventListener('error', function (event)
+ {
+ reportError('window.error', event.error || event.message);
+ });
+
+ window.addEventListener('unhandledrejection', function (event)
+ {
+ reportError('unhandledrejection', event.reason);
+ });
+
+ window.addEventListener('beforeunload', function ()
+ {
+ if (!state.fx)
+ {
+ return;
+ }
+
+ invokeFx('beforeunload', function ()
+ {
+ state.fx.destroy();
+ state.fx = null;
+ resetInputCache();
+ return true;
+ });
+ });
+
+ installCompatibilityShims();
+
+ if (document.readyState === 'loading')
+ {
+ document.addEventListener(
+ 'DOMContentLoaded',
+ initialize,
+ DOM_CONTENT_LOADED_OPTIONS,
+ );
+ }
+ else
+ {
+ initialize();
+ }
+})();
diff --git a/src/Web/index.html b/src/Web/index.html
index 74b1878..478b3e4 100644
--- a/src/Web/index.html
+++ b/src/Web/index.html
@@ -3,821 +3,24 @@
+
-
-
+
+
-