From d61f0ff17bda08b981bf90480e9cb772fdba9f3b Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 16:52:39 +0900
Subject: [PATCH 1/7] Feedback widgets
---
CHANGELOG.md | 7 +
countlyCommon/countlyCommon/CountlyBase.cs | 26 ++++
.../Entities/CountlyFeedbackWidget.cs | 47 ++++++
.../Helpers/WidgetActionParser.cs | 69 +++++++++
.../countlyCommon/Helpers/WidgetUrlBuilder.cs | 37 +++++
.../countlyCommon/Modules/ModuleFeedback.cs | 143 ++++++++++++++++++
net35/Countly/Countly.csproj | 9 ++
net45/Countly/Countly.csproj | 9 ++
netstd/Countly/Countly.csproj | 3 +
ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs | 2 +
.../Countly.UI.WebView2.Tests.csproj | 22 +++
.../FeedbackWidgetParserTests.cs | 36 +++++
.../FeedbackWidgetPresenterTests.cs | 77 ++++++++++
.../LocalMockServer.cs | 72 +++++++++
.../ModuleFeedbackAccessorTests.cs | 34 +++++
.../ModuleFeedbackNetworkTests.cs | 79 ++++++++++
.../WebView2AndEntryPointTests.cs | 39 +++++
.../WidgetActionParserTests.cs | 30 ++++
.../WidgetUrlBuilderTests.cs | 32 ++++
ui/Countly.UI.WebView2/CHANGELOG.md | 9 ++
.../Countly.UI.WebView2.csproj | 20 +++
.../CountlyWebView.WinForms.cs | 45 ++++++
ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs | 48 ++++++
ui/Countly.UI.WebView2/CountlyWebView.cs | 11 ++
.../FeedbackWidgetPresenter.cs | 56 +++++++
ui/Countly.UI.WebView2/IWidgetWebHost.cs | 25 +++
ui/Countly.UI.WebView2/WebView2Runtime.cs | 23 +++
.../WebView2WidgetHost.WinForms.cs | 43 ++++++
ui/Countly.UI.WebView2/WebView2WidgetHost.cs | 43 ++++++
ui/CountlyFeedbackDemo.Wpf/App.xaml | 6 +
ui/CountlyFeedbackDemo.Wpf/App.xaml.cs | 6 +
.../CountlyFeedbackDemo.Wpf.csproj | 14 ++
ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml | 15 ++
ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs | 50 ++++++
34 files changed, 1187 insertions(+)
create mode 100644 countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
create mode 100644 countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs
create mode 100644 countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs
create mode 100644 countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj
create mode 100644 ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs
create mode 100644 ui/Countly.UI.WebView2/CHANGELOG.md
create mode 100644 ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj
create mode 100644 ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
create mode 100644 ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
create mode 100644 ui/Countly.UI.WebView2/CountlyWebView.cs
create mode 100644 ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
create mode 100644 ui/Countly.UI.WebView2/IWidgetWebHost.cs
create mode 100644 ui/Countly.UI.WebView2/WebView2Runtime.cs
create mode 100644 ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs
create mode 100644 ui/Countly.UI.WebView2/WebView2WidgetHost.cs
create mode 100644 ui/CountlyFeedbackDemo.Wpf/App.xaml
create mode 100644 ui/CountlyFeedbackDemo.Wpf/App.xaml.cs
create mode 100644 ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj
create mode 100644 ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
create mode 100644 ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2301c9da..06064d79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@
* After initialization finishes.
* RemoteConfig consent is given.
* After the device ID is changed to a different user.
+* Added support for the Feedback Widgets feature (Surveys, NPS, Ratings) accessible through the "Countly.Instance.Feedback()" interface:
+ * "GetAvailableFeedbackWidgets" for fetching the list of available feedback widgets from the server
+ * "GetFeedbackWidgetData" for fetching a widget's definition (for building a custom UI)
+ * "ReportFeedbackWidgetManually" for reporting a widget result, or marking it closed
+ * "ConstructFeedbackWidgetUrl" for building a widget's display URL
+ * This feature uses "Feedback" consent (and "StarRating" consent for rating widgets).
+ * Needs "Countly.UI.WebView2" for displaying feedback widgets on WPF and WinForms.
## 25.4.3
* ! Minor breaking change ! User properties will now be automatically saved under the following conditions:
diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs
index d00505ba..4747d3d5 100644
--- a/countlyCommon/countlyCommon/CountlyBase.cs
+++ b/countlyCommon/countlyCommon/CountlyBase.cs
@@ -60,6 +60,7 @@ public enum LogLevel { VERBOSE, DEBUG, INFO, WARNING, ERROR };
internal CountlyConfig Configuration;
internal ModuleBackendMode moduleBackendMode;
internal ModuleRemoteConfig moduleRemoteConfig;
+ internal ModuleFeedback moduleFeedback;
public abstract string sdkName();
@@ -1210,6 +1211,7 @@ internal async Task HaltInternal(bool clearStorage = true)
// modules
moduleBackendMode = null;
moduleRemoteConfig = null;
+ moduleFeedback = null;
}
if (clearStorage) {
await ClearStorage();
@@ -1511,6 +1513,7 @@ protected async Task InitBase(CountlyConfig config)
}
moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl);
+ moduleFeedback = new ModuleFeedback(requestHelper, ServerUrl);
UtilityHelper.CountlyLogging("[CountlyBase] Finished 'InitBase'");
await OnInitComplete();
@@ -2011,5 +2014,28 @@ public RemoteConfig RemoteConfig()
return moduleRemoteConfig;
}
+
+ ///
+ /// Returns the Feedback interface for retrieving/displaying/reporting feedback widgets.
+ /// If backend mode is enabled, the module is unavailable, or Feedback consent is not
+ /// given, a no-op is returned instead.
+ ///
+ public Feedback Feedback()
+ {
+ if (Configuration.backendMode) {
+ UtilityHelper.CountlyLogging("[CountlyBase] Feedback, backend mode is enabled, will omit this call");
+ return new MockFeedback();
+ }
+
+ if (moduleFeedback == null) {
+ return new MockFeedback();
+ }
+
+ if (!IsConsentGiven(ConsentFeatures.Feedback)) {
+ return new MockFeedback();
+ }
+
+ return moduleFeedback;
+ }
}
}
diff --git a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
new file mode 100644
index 00000000..bfb24113
--- /dev/null
+++ b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using Newtonsoft.Json.Linq;
+
+namespace CountlySDK.CountlyCommon
+{
+ public enum FeedbackWidgetType { survey, nps, rating }
+
+ public class CountlyFeedbackWidget
+ {
+ public string widgetId;
+ public FeedbackWidgetType type;
+ public string name;
+ public List tags = new List();
+ }
+
+ public static class FeedbackWidgetParser
+ {
+ public static CountlyFeedbackWidget[] ParseAvailableWidgets(string responseText)
+ {
+ List result = new List();
+ if (string.IsNullOrEmpty(responseText)) { return result.ToArray(); }
+
+ try {
+ JObject root = JObject.Parse(responseText);
+ if (!(root["result"] is JArray arr)) { return result.ToArray(); }
+
+ foreach (JToken item in arr) {
+ string typeStr = (string)item["type"];
+ if (typeStr == null) { continue; }
+ if (!System.Enum.TryParse(typeStr, out FeedbackWidgetType type)) { continue; }
+
+ CountlyFeedbackWidget w = new CountlyFeedbackWidget {
+ widgetId = (string)item["_id"],
+ type = type,
+ name = (string)item["name"]
+ };
+ if (item["tg"] is JArray tags) {
+ foreach (JToken t in tags) { w.tags.Add((string)t); }
+ }
+ if (!string.IsNullOrEmpty(w.widgetId)) { result.Add(w); }
+ }
+ } catch { /* malformed response -> empty list */ }
+
+ return result.ToArray();
+ }
+ }
+}
diff --git a/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs b/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs
new file mode 100644
index 00000000..648b8a2d
--- /dev/null
+++ b/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json.Linq;
+
+namespace CountlySDK.CountlyCommon
+{
+ public class WidgetRect { public int X, Y, W, H; }
+
+ public class WidgetAction
+ {
+ public bool IsActionEvent;
+ public bool Close;
+ public bool HasResize;
+ public WidgetRect Portrait;
+ public WidgetRect Landscape;
+ }
+
+ public static class WidgetActionParser
+ {
+ private const string ActionHost = "countly_action_event";
+
+ public static WidgetAction Parse(string url)
+ {
+ WidgetAction action = new WidgetAction();
+ if (string.IsNullOrEmpty(url) || url.IndexOf(ActionHost, StringComparison.Ordinal) < 0) {
+ return action;
+ }
+ action.IsActionEvent = true;
+
+ Dictionary q = ParseQuery(url);
+ if (q.TryGetValue("close", out string close) && close == "1") {
+ action.Close = true;
+ }
+ if (q.TryGetValue("resize_me", out string resize) && !string.IsNullOrEmpty(resize)) {
+ try {
+ JObject obj = JObject.Parse(resize);
+ action.Portrait = ToRect(obj["p"]);
+ action.Landscape = ToRect(obj["l"]);
+ action.HasResize = action.Portrait != null || action.Landscape != null;
+ } catch { /* ignore malformed resize payload */ }
+ }
+ return action;
+ }
+
+ private static WidgetRect ToRect(JToken t)
+ {
+ if (t == null) { return null; }
+ return new WidgetRect {
+ X = (int?)t["x"] ?? 0, Y = (int?)t["y"] ?? 0,
+ W = (int?)t["w"] ?? 0, H = (int?)t["h"] ?? 0
+ };
+ }
+
+ private static Dictionary ParseQuery(string url)
+ {
+ Dictionary result = new Dictionary();
+ int qi = url.IndexOf('?');
+ if (qi < 0 || qi == url.Length - 1) { return result; }
+ foreach (string pair in url.Substring(qi + 1).Split('&')) {
+ int eq = pair.IndexOf('=');
+ if (eq <= 0) { continue; }
+ string key = pair.Substring(0, eq);
+ string val = Uri.UnescapeDataString(pair.Substring(eq + 1));
+ result[key] = val;
+ }
+ return result;
+ }
+ }
+}
diff --git a/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs b/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs
new file mode 100644
index 00000000..6c941cbb
--- /dev/null
+++ b/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace CountlySDK.CountlyCommon.Helpers
+{
+ ///
+ /// Builds the display URL for a feedback widget. Pure/UI-free so it can be unit-tested and
+ /// reused by the WebView2 UI package. The custom={"tc":1,"rw":1,"xb":1} object opts
+ /// into versioned/resizable widgets (so the widget emits resize/close action events).
+ ///
+ public static class WidgetUrlBuilder
+ {
+ public static string BuildFeedbackWidgetUrl(string serverUrl, CountlyFeedbackWidget widget, IDictionary baseParams)
+ {
+ StringBuilder sb = new StringBuilder();
+ sb.Append(serverUrl.TrimEnd('/'));
+ sb.Append("/feedback/").Append(widget.type).Append("?");
+ sb.Append("widget_id=").Append(Uri.EscapeDataString(widget.widgetId));
+ Append(sb, "device_id", baseParams, "device_id");
+ Append(sb, "app_key", baseParams, "app_key");
+ Append(sb, "sdk_name", baseParams, "sdk_name");
+ Append(sb, "sdk_version", baseParams, "sdk_version");
+ Append(sb, "app_version", baseParams, "av");
+ sb.Append("&platform=windows");
+ sb.Append("&custom=").Append(Uri.EscapeDataString("{\"tc\":1,\"rw\":1,\"xb\":1}"));
+ return sb.ToString();
+ }
+
+ private static void Append(StringBuilder sb, string outKey, IDictionary src, string srcKey)
+ {
+ if (src != null && src.TryGetValue(srcKey, out object v) && v != null) {
+ sb.Append("&").Append(outKey).Append("=").Append(Uri.EscapeDataString(Convert.ToString(v)));
+ }
+ }
+ }
+}
diff --git a/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
new file mode 100644
index 00000000..c248534c
--- /dev/null
+++ b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using CountlySDK.CountlyCommon.Helpers;
+using CountlySDK.CountlyCommon.Server.Responses;
+using CountlySDK.Helpers;
+using Newtonsoft.Json.Linq;
+using static CountlySDK.CountlyCommon.CountlyBase;
+
+namespace CountlySDK.CountlyCommon
+{
+ internal class ModuleFeedback : Feedback
+ {
+ private readonly RequestHelper requestHelper;
+ private readonly string ServerUrl;
+
+ // Server-contract event keys. CountlyBase declares these as `private const`, so they
+ // are re-declared here for the module (the exact string values must match, because
+ // CheckConsentOnKey routes them to Feedback/StarRating consent).
+ private const string NPS_EVENT_KEY = "[CLY]_nps";
+ private const string SURVEY_EVENT_KEY = "[CLY]_survey";
+ private const string STAR_RATING_EVENT_KEY = "[CLY]_star_rating";
+
+ public ModuleFeedback(RequestHelper requestHelper, string serverUrl)
+ {
+ this.requestHelper = requestHelper;
+ ServerUrl = serverUrl;
+ }
+
+ public async Task GetAvailableFeedbackWidgets()
+ {
+ UtilityHelper.CountlyLogging("[ModuleFeedback] GetAvailableFeedbackWidgets called");
+
+ IDictionary parameters = new Dictionary {
+ { "method", "feedback" }
+ };
+
+ RequestResult requestResult = await Api.Instance.SendDirectRequest(ServerUrl, await requestHelper.BuildRequest(parameters));
+
+ if (requestResult != null && requestResult.responseCode == 200 && requestResult.responseText != null) {
+ return FeedbackWidgetParser.ParseAvailableWidgets(requestResult.responseText);
+ }
+
+ UtilityHelper.CountlyLogging("[ModuleFeedback] GetAvailableFeedbackWidgets, request failed", LogLevel.ERROR);
+ return new CountlyFeedbackWidget[0];
+ }
+
+ public async Task GetFeedbackWidgetData(CountlyFeedbackWidget widget)
+ {
+ if (widget == null) {
+ UtilityHelper.CountlyLogging("[ModuleFeedback] GetFeedbackWidgetData, widget is null", LogLevel.ERROR);
+ return null;
+ }
+ UtilityHelper.CountlyLogging("[ModuleFeedback] GetFeedbackWidgetData for widget [" + widget.widgetId + "]");
+
+ IDictionary parameters = new Dictionary {
+ { "widget_id", widget.widgetId },
+ { "shown", "1" }
+ };
+ string endpoint = "/o/surveys/" + widget.type + "/widget";
+
+ RequestResult requestResult = await Api.Instance.SendDirectRequest(ServerUrl, await requestHelper.BuildRequest(parameters), endpoint);
+
+ if (requestResult != null && requestResult.responseCode == 200 && requestResult.responseText != null) {
+ try {
+ return JObject.Parse(requestResult.responseText);
+ } catch {
+ UtilityHelper.CountlyLogging("[ModuleFeedback] GetFeedbackWidgetData, failed to parse response", LogLevel.ERROR);
+ }
+ }
+ return null;
+ }
+
+ public async Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult)
+ {
+ if (widget == null) {
+ UtilityHelper.CountlyLogging("[ModuleFeedback] ReportFeedbackWidgetManually, widget is null, ignoring", LogLevel.ERROR);
+ return;
+ }
+
+ string eventKey;
+ switch (widget.type) {
+ case FeedbackWidgetType.nps: eventKey = NPS_EVENT_KEY; break;
+ case FeedbackWidgetType.survey: eventKey = SURVEY_EVENT_KEY; break;
+ default: eventKey = STAR_RATING_EVENT_KEY; break;
+ }
+
+ // Stable Add() order: platform, app_version, widget_id, then result (or closed).
+ Segmentation segmentation = new Segmentation();
+ segmentation.Add("platform", "windows");
+ segmentation.Add("app_version", new IRequestHelperImpl(Countly.Instance).GetAppVersion());
+ segmentation.Add("widget_id", widget.widgetId);
+
+ if (widgetResult == null) {
+ segmentation.Add("closed", "1");
+ } else {
+ foreach (KeyValuePair kv in widgetResult) {
+ segmentation.Add(kv.Key, Convert.ToString(kv.Value));
+ }
+ }
+
+ // RecordEventInternal is protected; a module must use the public static entrypoint.
+ await Countly.RecordEvent(eventKey, 1, segmentation);
+ }
+
+ public async Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget)
+ {
+ if (widget == null) { return null; }
+ IDictionary baseParams = await requestHelper.GetBaseParams();
+ return WidgetUrlBuilder.BuildFeedbackWidgetUrl(ServerUrl, widget, baseParams);
+ }
+ }
+
+ internal class MockFeedback : Feedback
+ {
+ public Task GetAvailableFeedbackWidgets() { return Task.FromResult(new CountlyFeedbackWidget[0]); }
+ public Task GetFeedbackWidgetData(CountlyFeedbackWidget widget) { return Task.FromResult(null); }
+ public Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult) { return Task.FromResult(0); }
+ public Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget) { return Task.FromResult(null); }
+ }
+
+ ///
+ /// Defines the contract for retrieving, displaying, and reporting Countly feedback widgets
+ /// (Surveys, NPS, Ratings).
+ ///
+ public interface Feedback
+ {
+ /// Fetches the list of feedback widgets available for the current device.
+ Task GetAvailableFeedbackWidgets();
+
+ /// Fetches the raw widget definition JSON (for building a custom UI).
+ Task GetFeedbackWidgetData(CountlyFeedbackWidget widget);
+
+ ///
+ /// Reports a widget result manually. Pass as null to
+ /// mark the widget closed/cancelled without an answer.
+ ///
+ Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult);
+
+ /// Builds the display URL for a widget (used by the WebView2 UI package).
+ Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget);
+ }
+}
diff --git a/net35/Countly/Countly.csproj b/net35/Countly/Countly.csproj
index cee3cd53..c72b0008 100644
--- a/net35/Countly/Countly.csproj
+++ b/net35/Countly/Countly.csproj
@@ -78,6 +78,9 @@
Entities\CountlyEvent.cs
+
+ Entities\CountlyFeedbackWidget.cs
+
Entities\CountlyUserDetails.cs
@@ -123,6 +126,12 @@
Helpers\RequestHelper.cs
+
+ Helpers\WidgetActionParser.cs
+
+
+ Helpers\WidgetUrlBuilder.cs
+
Helpers\StorageBase.cs
diff --git a/net45/Countly/Countly.csproj b/net45/Countly/Countly.csproj
index 85a9eaf7..3b037462 100644
--- a/net45/Countly/Countly.csproj
+++ b/net45/Countly/Countly.csproj
@@ -78,6 +78,9 @@
Entities\CountlyEvent.cs
+
+ Entities\CountlyFeedbackWidget.cs
+
Entities\CountlyUserDetails.cs
@@ -123,6 +126,12 @@
Helpers\RequestHelper.cs
+
+ Helpers\WidgetActionParser.cs
+
+
+ Helpers\WidgetUrlBuilder.cs
+
Helpers\StorageBase.cs
diff --git a/netstd/Countly/Countly.csproj b/netstd/Countly/Countly.csproj
index 33c35d1d..5887a407 100644
--- a/netstd/Countly/Countly.csproj
+++ b/netstd/Countly/Countly.csproj
@@ -29,6 +29,7 @@
+
@@ -46,6 +47,8 @@
+
+
diff --git a/ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs b/ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs
new file mode 100644
index 00000000..679b372e
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs
@@ -0,0 +1,2 @@
+// Init-based tests drive the shared static Countly singleton, so serialize the whole assembly.
+[assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)]
diff --git a/ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj b/ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj
new file mode 100644
index 00000000..bb90ea72
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+ net8.0-windows
+ true
+ true
+ disable
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs
new file mode 100644
index 00000000..4ab4f1d2
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+using CountlySDK.CountlyCommon;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ public class FeedbackWidgetParserTests
+ {
+ [Fact]
+ public void ParseAvailableWidgets_ParsesAllTypes()
+ {
+ string json = "{\"result\":[" +
+ "{\"_id\":\"id_s\",\"type\":\"survey\",\"name\":\"S\",\"tg\":[\"/a\"]}," +
+ "{\"_id\":\"id_n\",\"type\":\"nps\",\"name\":\"N\",\"tg\":[]}," +
+ "{\"_id\":\"id_r\",\"type\":\"rating\",\"name\":\"R\",\"tg\":[\"/b\",\"/c\"]}]}";
+
+ CountlyFeedbackWidget[] widgets = FeedbackWidgetParser.ParseAvailableWidgets(json);
+
+ Assert.Equal(3, widgets.Length);
+ Assert.Equal("id_s", widgets[0].widgetId);
+ Assert.Equal(FeedbackWidgetType.survey, widgets[0].type);
+ Assert.Equal("N", widgets[1].name);
+ Assert.Equal(FeedbackWidgetType.nps, widgets[1].type);
+ Assert.Equal(new List { "/b", "/c" }, widgets[2].tags);
+ }
+
+ [Fact]
+ public void ParseAvailableWidgets_InvalidOrEmpty_ReturnsEmpty()
+ {
+ Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets(""));
+ Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("{}"));
+ Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("{\"result\":\"nope\"}"));
+ Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("not json"));
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs
new file mode 100644
index 00000000..009d0f19
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using CountlySDK.CountlyCommon;
+using CountlySDK.UI;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ internal sealed class FakeHost : IWidgetWebHost
+ {
+ public event Action NavigationStarting;
+ public string NavigatedUrl;
+ public (int w, int h)? Resized;
+ public bool Closed;
+
+ public void Navigate(string url) { NavigatedUrl = url; }
+ public void ResizeTo(int w, int h) { Resized = (w, h); }
+ public void CloseHost() { Closed = true; }
+ public void Fire(string url) { NavigationStarting?.Invoke(url); }
+ }
+
+ internal sealed class FakeFeedback : Feedback
+ {
+ public bool ReportedClose;
+ public Task GetAvailableFeedbackWidgets() { return Task.FromResult(new CountlyFeedbackWidget[0]); }
+ public Task GetFeedbackWidgetData(CountlyFeedbackWidget w) { return Task.FromResult(null); }
+ public Task ReportFeedbackWidgetManually(CountlyFeedbackWidget w, JObject d, Dictionary r)
+ {
+ if (r == null) { ReportedClose = true; }
+ return Task.FromResult(0);
+ }
+ public Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget w) { return Task.FromResult("https://s/feedback/nps?widget_id=" + w.widgetId); }
+ }
+
+ public class FeedbackWidgetPresenterTests
+ {
+ [Fact]
+ public async Task Start_NavigatesToConstructedUrl()
+ {
+ FakeHost host = new FakeHost();
+ FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), isLandscape: false, onClosed: () => { });
+ await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps });
+ Assert.Equal("https://s/feedback/nps?widget_id=w1", host.NavigatedUrl);
+ }
+
+ [Fact]
+ public async Task Resize_PicksPortraitRect_WhenPortrait()
+ {
+ FakeHost host = new FakeHost();
+ FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), isLandscape: false, onClosed: () => { });
+ await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps });
+
+ host.Fire("https://countly_action_event?cly_widget_command=1&action=resize_me&resize_me=" +
+ Uri.EscapeDataString("{\"p\":{\"x\":0,\"y\":0,\"w\":320,\"h\":480},\"l\":{\"x\":0,\"y\":0,\"w\":800,\"h\":600}}"));
+
+ Assert.Equal((320, 480), host.Resized);
+ }
+
+ [Fact]
+ public async Task Close_ReportsClosedAndClosesHost()
+ {
+ FakeHost host = new FakeHost();
+ FakeFeedback fb = new FakeFeedback();
+ bool closedCb = false;
+ FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, fb, isLandscape: false, onClosed: () => closedCb = true);
+ await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps });
+
+ host.Fire("https://countly_action_event?cly_widget_command=1&close=1");
+
+ Assert.True(fb.ReportedClose);
+ Assert.True(host.Closed);
+ Assert.True(closedCb);
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
new file mode 100644
index 00000000..dcfabb30
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+
+namespace Countly.UI.WebView2.Tests
+{
+ // Minimal self-contained HTTP mock (no dependency on the SDK's TestProject_common helpers,
+ // which reach SDK internals not visible from this external assembly). Records requests and
+ // returns JSON from an optional responder (rawUrl, body) => json.
+ internal sealed class LocalMockServer : IDisposable
+ {
+ private readonly HttpListener _listener;
+ private readonly Func _responder;
+
+ public string Url { get; }
+ public List Requests { get; } = new List();
+
+ public LocalMockServer(Func responder = null)
+ {
+ _responder = responder;
+ int port = FreePort();
+ Url = $"http://localhost:{port}/";
+ _listener = new HttpListener();
+ _listener.Prefixes.Add(Url);
+ _listener.Start();
+ Thread t = new Thread(Loop) { IsBackground = true };
+ t.Start();
+ }
+
+ private void Loop()
+ {
+ while (_listener.IsListening) {
+ try {
+ HttpListenerContext ctx = _listener.GetContext();
+ string body;
+ using (StreamReader r = new StreamReader(ctx.Request.InputStream)) { body = r.ReadToEnd(); }
+ lock (Requests) { Requests.Add(new Captured { RawUrl = ctx.Request.RawUrl, Method = ctx.Request.HttpMethod, Body = body }); }
+
+ string json = _responder?.Invoke(ctx.Request.RawUrl, body) ?? "{\"result\":\"success\"}";
+ byte[] buf = Encoding.UTF8.GetBytes(json);
+ ctx.Response.StatusCode = 200;
+ ctx.Response.ContentType = "application/json";
+ ctx.Response.ContentLength64 = buf.Length;
+ ctx.Response.OutputStream.Write(buf, 0, buf.Length);
+ ctx.Response.Close();
+ } catch { /* listener stopped */ }
+ }
+ }
+
+ private static int FreePort()
+ {
+ TcpListener l = new TcpListener(IPAddress.Loopback, 0);
+ l.Start();
+ int p = ((IPEndPoint)l.LocalEndpoint).Port;
+ l.Stop();
+ return p;
+ }
+
+ public void Dispose() { try { _listener.Stop(); } catch { } }
+
+ public sealed class Captured
+ {
+ public string RawUrl;
+ public string Method;
+ public string Body;
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs
new file mode 100644
index 00000000..83218f18
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs
@@ -0,0 +1,34 @@
+using CountlySDK.CountlyCommon;
+using CountlySDK.Entities;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ // Runtime verification of the Feedback() accessor wiring (T2). Uses only PUBLIC API, so it
+ // works from this external harness (no SDK internals needed). Confirms Countly.Init runs
+ // under the net8.0-windows host and that consent gating returns the no-op MockFeedback.
+ // Note: 'Countly' must be fully qualified as CountlySDK.Countly here, because the test's own
+ // 'Countly.UI.WebView2.Tests' namespace shadows the CountlySDK.Countly class name.
+ public class ModuleFeedbackAccessorTests
+ {
+ [Fact]
+ public void Feedback_ReturnsMock_WhenConsentRequiredAndNotGiven()
+ {
+ CountlySDK.Countly.Halt();
+ CountlyConfig cc = new CountlyConfig {
+ serverUrl = "https://no-server.count.ly",
+ appKey = "APP_KEY",
+ appVersion = "1.0",
+ consentRequired = true
+ };
+ CountlySDK.Countly.Instance.Init(cc).Wait();
+
+ // Consent for Feedback not granted -> accessor returns MockFeedback -> empty, never null, no throw.
+ CountlyFeedbackWidget[] widgets = CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets().Result;
+
+ Assert.NotNull(widgets);
+ Assert.Empty(widgets);
+ CountlySDK.Countly.Halt();
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs
new file mode 100644
index 00000000..120e9cf2
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs
@@ -0,0 +1,79 @@
+using System.Collections.Generic;
+using System.Linq;
+using CountlySDK.CountlyCommon;
+using CountlySDK.Entities;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ // Local integration tests for the feedback network layer (T3/T4/T5). Uses only PUBLIC SDK
+ // API + a local mock server, so they run from this external harness. (The event-queue
+ // assertion for T5 that inspects Countly.Instance.Events directly lives in the source-linked
+ // TestProject_common test for the VS build; here we verify T5 via the observable /i upload.)
+ public class ModuleFeedbackNetworkTests
+ {
+ [Fact]
+ public void GetAvailableFeedbackWidgets_FetchesAndParses()
+ {
+ using LocalMockServer server = new LocalMockServer((rawUrl, body) =>
+ body != null && body.Contains("method=feedback")
+ ? "{\"result\":[{\"_id\":\"w1\",\"type\":\"nps\",\"name\":\"N\",\"tg\":[]}]}"
+ : null);
+
+ CountlySDK.Countly.Halt();
+ CountlySDK.Countly.Instance.Init(new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }).Wait();
+
+ CountlyFeedbackWidget[] widgets = CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets().Result;
+
+ Assert.Single(widgets);
+ Assert.Equal("w1", widgets[0].widgetId);
+ Assert.Equal(FeedbackWidgetType.nps, widgets[0].type);
+ CountlySDK.Countly.Halt();
+ }
+
+ [Fact]
+ public void GetFeedbackWidgetData_HitsSurveyEndpointAndParses()
+ {
+ using LocalMockServer server = new LocalMockServer((rawUrl, body) =>
+ rawUrl.Contains("/o/surveys/") ? "{\"name\":\"My NPS\"}" : null);
+
+ CountlySDK.Countly.Halt();
+ CountlySDK.Countly.Instance.Init(new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }).Wait();
+
+ CountlyFeedbackWidget widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps };
+ JObject data = CountlySDK.Countly.Instance.Feedback().GetFeedbackWidgetData(widget).Result;
+
+ Assert.NotNull(data);
+ Assert.Equal("My NPS", (string)data["name"]);
+
+ LocalMockServer.Captured req = server.Requests.Last(r => r.RawUrl.Contains("/o/surveys/"));
+ Assert.Contains("/o/surveys/nps/widget", req.RawUrl);
+ Assert.Contains("widget_id=w1", req.RawUrl + req.Body);
+ Assert.Contains("shown=1", req.RawUrl + req.Body);
+ CountlySDK.Countly.Halt();
+ }
+
+ [Fact]
+ public void ReportFeedbackWidgetManually_UploadsNpsEventWithWidgetSegmentation()
+ {
+ using LocalMockServer server = new LocalMockServer((rawUrl, body) => "{\"result\":\"success\"}");
+
+ CountlySDK.Countly.Halt();
+ CountlySDK.Countly.Instance.Init(new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }).Wait();
+
+ CountlyFeedbackWidget widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps };
+ Dictionary result = new Dictionary { { "rating", 9 } };
+ CountlySDK.Countly.Instance.Feedback().ReportFeedbackWidgetManually(widget, null, result).Wait();
+
+ // The [CLY]_nps event is uploaded to /i; its (url-encoded) body must carry the widget segmentation.
+ bool found = server.Requests.Any(r => {
+ string decoded = System.Uri.UnescapeDataString(r.Body ?? "");
+ return decoded.Contains("[CLY]_nps") && decoded.Contains("widget_id") && decoded.Contains("w1");
+ });
+ Assert.True(found, "No uploaded request carried the NPS feedback event. Captured bodies: " +
+ string.Join(" || ", server.Requests.Select(r => r.Body)));
+ CountlySDK.Countly.Halt();
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs b/ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs
new file mode 100644
index 00000000..e305835b
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs
@@ -0,0 +1,39 @@
+using System;
+using CountlySDK.CountlyCommon;
+using CountlySDK.UI;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ // T10-T12 verification that does NOT require a UI thread or the WebView2 runtime:
+ // the runtime probe is tolerant, and the entry points are asserted to exist by reflection
+ // (the real WebView2 display path is exercised via the sample apps).
+ public class WebView2AndEntryPointTests
+ {
+ [Fact]
+ public void WebView2Runtime_IsAvailable_DoesNotThrow()
+ {
+ bool available = WebView2Runtime.IsAvailable(out string version);
+ // On CI without the runtime this is false + null; on a dev machine it may be true.
+ Assert.True(available || version == null);
+ }
+
+ [Fact]
+ public void PresentFeedbackWidget_Wpf_SignatureExists()
+ {
+ System.Reflection.MethodInfo m = typeof(CountlyWebView).GetMethod(
+ "PresentFeedbackWidget",
+ new[] { typeof(System.Windows.Window), typeof(CountlyFeedbackWidget), typeof(Action) });
+ Assert.NotNull(m);
+ }
+
+ [Fact]
+ public void PresentFeedbackWidget_WinForms_SignatureExists()
+ {
+ System.Reflection.MethodInfo m = typeof(CountlyWebView).GetMethod(
+ "PresentFeedbackWidget",
+ new[] { typeof(System.Windows.Forms.IWin32Window), typeof(CountlyFeedbackWidget), typeof(Action) });
+ Assert.NotNull(m);
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs
new file mode 100644
index 00000000..9d5bce6b
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs
@@ -0,0 +1,30 @@
+using System;
+using CountlySDK.CountlyCommon;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ public class WidgetActionParserTests
+ {
+ [Fact]
+ public void ParsesResizeAndClose()
+ {
+ string resizeUrl = "https://countly_action_event?cly_widget_command=1&action=resize_me&resize_me=" +
+ Uri.EscapeDataString("{\"p\":{\"x\":1,\"y\":2,\"w\":300,\"h\":400},\"l\":{\"x\":5,\"y\":6,\"w\":700,\"h\":500}}");
+ WidgetAction a = WidgetActionParser.Parse(resizeUrl);
+ Assert.True(a.IsActionEvent);
+ Assert.True(a.HasResize);
+ Assert.Equal(300, a.Portrait.W);
+ Assert.Equal(400, a.Portrait.H);
+ Assert.Equal(700, a.Landscape.W);
+ Assert.False(a.Close);
+
+ WidgetAction c = WidgetActionParser.Parse("https://countly_action_event?cly_widget_command=1&close=1");
+ Assert.True(c.IsActionEvent);
+ Assert.True(c.Close);
+
+ WidgetAction n = WidgetActionParser.Parse("https://example.com/feedback/nps?widget_id=w1");
+ Assert.False(n.IsActionEvent);
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs
new file mode 100644
index 00000000..288b89b6
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using CountlySDK.CountlyCommon;
+using CountlySDK.CountlyCommon.Helpers;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ public class WidgetUrlBuilderTests
+ {
+ [Fact]
+ public void BuildFeedbackWidgetUrl_ContainsRequiredParamsAndCustom()
+ {
+ var baseParams = new Dictionary {
+ { "app_key", "APPKEY" }, { "device_id", "DEV1" },
+ { "sdk_name", "csharp" }, { "sdk_version", "9.9.9" }, { "av", "1.2.3" }
+ };
+ var widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.survey };
+
+ string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly", widget, baseParams);
+
+ Assert.StartsWith("https://s.count.ly/feedback/survey?", url);
+ Assert.Contains("widget_id=w1", url);
+ Assert.Contains("device_id=DEV1", url);
+ Assert.Contains("app_key=APPKEY", url);
+ Assert.Contains("platform=windows", url);
+ Assert.Contains("sdk_version=9.9.9", url);
+ Assert.Contains("app_version=1.2.3", url);
+ Assert.Contains("%22rw%22%3A1", url); // "rw":1 (resizable opt-in), url-encoded
+ Assert.Contains("%22tc%22%3A1", url);
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/CHANGELOG.md b/ui/Countly.UI.WebView2/CHANGELOG.md
new file mode 100644
index 00000000..a994a0b9
--- /dev/null
+++ b/ui/Countly.UI.WebView2/CHANGELOG.md
@@ -0,0 +1,9 @@
+## XX.XX.XX
+* Initial release.
+* Added "CountlyWebView.PresentFeedbackWidget(...)" for displaying Countly feedback widgets (Surveys, NPS, Ratings) in a WebView2 host:
+ * WPF overload: "PresentFeedbackWidget(System.Windows.Window owner, CountlyFeedbackWidget widget, Action onClosed = null)"
+ * WinForms overload: "PresentFeedbackWidget(System.Windows.Forms.IWin32Window owner, CountlyFeedbackWidget widget, Action onClosed = null)"
+ * Dynamically resizes the host window to the widget's requested size and auto-reports the result when the widget is closed.
+* Added "WebView2Runtime.IsAvailable(out string version)" to detect the WebView2 Evergreen Runtime; when the runtime is missing, presentation is a graceful no-op.
+* Targets .NET Framework 4.6.2 and .NET 8 (Windows). Depends on the "Countly" SDK package and "Microsoft.Web.WebView2".
+* Requires the WebView2 Evergreen Runtime to be installed on the end-user machine.
diff --git a/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj b/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj
new file mode 100644
index 00000000..abea2e2d
--- /dev/null
+++ b/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj
@@ -0,0 +1,20 @@
+
+
+ net462;net8.0-windows
+ true
+ true
+ CountlySDK.UI
+ Countly.UI.WebView2
+ latest
+ false
+ false
+
+
+
+
+
+
+
+
+
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
new file mode 100644
index 00000000..a9e44d89
--- /dev/null
+++ b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Drawing;
+using System.Windows.Forms;
+using CountlySDK.CountlyCommon;
+using Microsoft.Web.WebView2.WinForms;
+
+namespace CountlySDK.UI
+{
+ public static partial class CountlyWebView
+ {
+ ///
+ /// Presents a feedback widget in a WebView2-hosted WinForms form owned by
+ /// . Must be called on the UI thread. If the WebView2 runtime is
+ /// missing, this is a graceful no-op (logs and invokes ).
+ ///
+ public static void PresentFeedbackWidget(IWin32Window owner, CountlyFeedbackWidget widget, Action onClosed = null)
+ {
+ if (widget == null) { onClosed?.Invoke(); return; }
+
+ if (!WebView2Runtime.IsAvailable(out _)) {
+ System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; cannot present widget");
+ onClosed?.Invoke();
+ return;
+ }
+
+ Form form = new Form {
+ FormBorderStyle = FormBorderStyle.None,
+ StartPosition = FormStartPosition.CenterParent,
+ ClientSize = new Size(400, 600),
+ ShowInTaskbar = false
+ };
+ WebView2 webView = new WebView2 { Dock = DockStyle.Fill };
+ form.Controls.Add(webView);
+
+ WinFormsWebView2WidgetHost adapter = new WinFormsWebView2WidgetHost(webView, form);
+ FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape: false, onClosed);
+
+ form.Load += async (s, e) => {
+ await adapter.InitializeAsync();
+ await presenter.StartAsync(widget);
+ };
+ form.Show(owner);
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
new file mode 100644
index 00000000..b2a0da98
--- /dev/null
+++ b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Windows;
+using CountlySDK.CountlyCommon;
+using Microsoft.Web.WebView2.Wpf;
+
+namespace CountlySDK.UI
+{
+ public static partial class CountlyWebView
+ {
+ ///
+ /// Presents a feedback widget in a WebView2-hosted WPF window owned by
+ /// . Must be called on the UI thread. If the WebView2 runtime is
+ /// missing, this is a graceful no-op (logs and invokes ).
+ ///
+ public static void PresentFeedbackWidget(Window owner, CountlyFeedbackWidget widget, Action onClosed = null)
+ {
+ if (widget == null) { onClosed?.Invoke(); return; }
+
+ if (!WebView2Runtime.IsAvailable(out _)) {
+ System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; cannot present widget");
+ onClosed?.Invoke();
+ return;
+ }
+
+ Window host = new Window {
+ Owner = owner,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Width = 400,
+ Height = 600,
+ ResizeMode = ResizeMode.NoResize,
+ WindowStyle = WindowStyle.None,
+ ShowInTaskbar = false
+ };
+ WebView2 webView = new WebView2();
+ host.Content = webView;
+
+ WebView2WidgetHost adapter = new WebView2WidgetHost(webView, host);
+ bool isLandscape = owner != null && owner.ActualWidth >= owner.ActualHeight;
+ FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape, onClosed);
+
+ host.Loaded += async (s, e) => {
+ await adapter.InitializeAsync();
+ await presenter.StartAsync(widget);
+ };
+ host.Show();
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.cs b/ui/Countly.UI.WebView2/CountlyWebView.cs
new file mode 100644
index 00000000..1f3bfbe0
--- /dev/null
+++ b/ui/Countly.UI.WebView2/CountlyWebView.cs
@@ -0,0 +1,11 @@
+namespace CountlySDK.UI
+{
+ ///
+ /// Entry point for displaying Countly feedback widgets in a WebView2 host.
+ /// Platform-specific overloads (WPF Window, WinForms IWin32Window) are added
+ /// as partial definitions in the per-framework files.
+ ///
+ public static partial class CountlyWebView
+ {
+ }
+}
diff --git a/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs b/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
new file mode 100644
index 00000000..67b6d660
--- /dev/null
+++ b/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Threading.Tasks;
+using CountlySDK.CountlyCommon;
+
+namespace CountlySDK.UI
+{
+ ///
+ /// Drives a feedback widget in an : builds the display URL,
+ /// interprets the widget's countly_action_event navigations (resize / close), reports
+ /// the result, and notifies on close. Contains ALL the display logic and no WebView2 types,
+ /// so it is fully unit-testable with a fake host.
+ ///
+ public class FeedbackWidgetPresenter
+ {
+ private readonly IWidgetWebHost _host;
+ private readonly Feedback _feedback;
+ private readonly bool _isLandscape;
+ private readonly Action _onClosed;
+ private CountlyFeedbackWidget _widget;
+
+ public FeedbackWidgetPresenter(IWidgetWebHost host, Feedback feedback, bool isLandscape, Action onClosed)
+ {
+ _host = host;
+ _feedback = feedback;
+ _isLandscape = isLandscape;
+ _onClosed = onClosed;
+ _host.NavigationStarting += OnNavigationStarting;
+ }
+
+ /// Fetches the widget's display URL and navigates the host to it.
+ public async Task StartAsync(CountlyFeedbackWidget widget)
+ {
+ _widget = widget;
+ string url = await _feedback.ConstructFeedbackWidgetUrl(widget);
+ _host.Navigate(url);
+ }
+
+ private void OnNavigationStarting(string url)
+ {
+ WidgetAction action = WidgetActionParser.Parse(url);
+ if (!action.IsActionEvent) { return; }
+
+ if (action.HasResize) {
+ WidgetRect r = _isLandscape ? (action.Landscape ?? action.Portrait) : (action.Portrait ?? action.Landscape);
+ if (r != null) { _host.ResizeTo(r.W, r.H); }
+ }
+
+ if (action.Close) {
+ // Report the widget as closed/cancelled (null result), then dismiss.
+ _ = _feedback.ReportFeedbackWidgetManually(_widget, null, null);
+ _host.CloseHost();
+ _onClosed?.Invoke();
+ }
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/IWidgetWebHost.cs b/ui/Countly.UI.WebView2/IWidgetWebHost.cs
new file mode 100644
index 00000000..4c8e241b
--- /dev/null
+++ b/ui/Countly.UI.WebView2/IWidgetWebHost.cs
@@ -0,0 +1,25 @@
+using System;
+
+namespace CountlySDK.UI
+{
+ ///
+ /// Abstraction over the embedded browser that hosts a widget. Keeps all presentation logic
+ /// in testable without instantiating a real WebView2
+ /// (which requires a UI thread + the WebView2 runtime). The concrete adapters
+ /// (WebView2WidgetHost / WinFormsWebView2WidgetHost) are thin implementations.
+ ///
+ public interface IWidgetWebHost
+ {
+ /// Raised for each navigation the widget initiates; the argument is the target URI.
+ event Action NavigationStarting;
+
+ /// Loads the given URL in the host.
+ void Navigate(string url);
+
+ /// Resizes the owning window to the widget's requested content size (CSS px).
+ void ResizeTo(int cssWidth, int cssHeight);
+
+ /// Closes/dismisses the host window.
+ void CloseHost();
+ }
+}
diff --git a/ui/Countly.UI.WebView2/WebView2Runtime.cs b/ui/Countly.UI.WebView2/WebView2Runtime.cs
new file mode 100644
index 00000000..20cfb27e
--- /dev/null
+++ b/ui/Countly.UI.WebView2/WebView2Runtime.cs
@@ -0,0 +1,23 @@
+using Microsoft.Web.WebView2.Core;
+
+namespace CountlySDK.UI
+{
+ /// Detects whether the WebView2 Evergreen Runtime is available on the machine.
+ public static class WebView2Runtime
+ {
+ ///
+ /// Returns true if the WebView2 runtime is installed. receives
+ /// the installed version string, or null if unavailable. Never throws.
+ ///
+ public static bool IsAvailable(out string version)
+ {
+ version = null;
+ try {
+ version = CoreWebView2Environment.GetAvailableBrowserVersionString();
+ return !string.IsNullOrEmpty(version);
+ } catch {
+ return false;
+ }
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs b/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs
new file mode 100644
index 00000000..48d17c00
--- /dev/null
+++ b/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Drawing;
+using System.Threading.Tasks;
+using System.Windows.Forms;
+using Microsoft.Web.WebView2.WinForms;
+
+namespace CountlySDK.UI
+{
+ ///
+ /// Thin WinForms adapter mapping onto a WebView2 control hosted
+ /// in a caller-owned . Logic-free by design.
+ ///
+ internal sealed class WinFormsWebView2WidgetHost : IWidgetWebHost
+ {
+ public event Action NavigationStarting;
+
+ private readonly WebView2 _webView;
+ private readonly Form _form;
+
+ public WinFormsWebView2WidgetHost(WebView2 webView, Form form)
+ {
+ _webView = webView;
+ _form = form;
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _webView.EnsureCoreWebView2Async(null);
+ _webView.CoreWebView2.NavigationStarting += (s, e) => NavigationStarting?.Invoke(e.Uri);
+ }
+
+ public void Navigate(string url) { _webView.CoreWebView2.Navigate(url); }
+
+ public void ResizeTo(int cssWidth, int cssHeight)
+ {
+ // WinForms is pixel-based; convert CSS px to device px for the form's current DPI.
+ float scale = _form.DeviceDpi / 96f;
+ _form.ClientSize = new Size((int)(cssWidth * scale), (int)(cssHeight * scale));
+ }
+
+ public void CloseHost() { _form.Close(); }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/WebView2WidgetHost.cs b/ui/Countly.UI.WebView2/WebView2WidgetHost.cs
new file mode 100644
index 00000000..c55ae9d8
--- /dev/null
+++ b/ui/Countly.UI.WebView2/WebView2WidgetHost.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows;
+using Microsoft.Web.WebView2.Wpf;
+
+namespace CountlySDK.UI
+{
+ ///
+ /// Thin WPF adapter mapping onto a WebView2 control hosted in a
+ /// caller-owned . Intentionally logic-free — all behavior lives in
+ /// .
+ ///
+ internal sealed class WebView2WidgetHost : IWidgetWebHost
+ {
+ public event Action NavigationStarting;
+
+ private readonly WebView2 _webView;
+ private readonly Window _window;
+
+ public WebView2WidgetHost(WebView2 webView, Window window)
+ {
+ _webView = webView;
+ _window = window;
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _webView.EnsureCoreWebView2Async(null);
+ _webView.CoreWebView2.NavigationStarting += (s, e) => NavigationStarting?.Invoke(e.Uri);
+ }
+
+ public void Navigate(string url) { _webView.CoreWebView2.Navigate(url); }
+
+ public void ResizeTo(int cssWidth, int cssHeight)
+ {
+ // WPF sizes are device-independent units (96=1.0), which match WebView2's CSS px.
+ _window.Width = cssWidth;
+ _window.Height = cssHeight;
+ }
+
+ public void CloseHost() { _window.Close(); }
+ }
+}
diff --git a/ui/CountlyFeedbackDemo.Wpf/App.xaml b/ui/CountlyFeedbackDemo.Wpf/App.xaml
new file mode 100644
index 00000000..79409c89
--- /dev/null
+++ b/ui/CountlyFeedbackDemo.Wpf/App.xaml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs b/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs
new file mode 100644
index 00000000..d5b1d65f
--- /dev/null
+++ b/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs
@@ -0,0 +1,6 @@
+namespace CountlyFeedbackDemo.Wpf
+{
+ public partial class App : System.Windows.Application
+ {
+ }
+}
diff --git a/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj b/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj
new file mode 100644
index 00000000..4117e2f1
--- /dev/null
+++ b/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj
@@ -0,0 +1,14 @@
+
+
+ WinExe
+ net8.0-windows
+ true
+ disable
+ false
+ false
+
+
+
+
+
+
diff --git a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
new file mode 100644
index 00000000..3894f53f
--- /dev/null
+++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
new file mode 100644
index 00000000..209f4910
--- /dev/null
+++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Linq;
+using System.Windows;
+using CountlySDK.CountlyCommon;
+using CountlySDK.Entities;
+using CountlySDK.UI;
+
+namespace CountlyFeedbackDemo.Wpf
+{
+ public partial class MainWindow : Window
+ {
+ private CountlyFeedbackWidget[] _widgets = new CountlyFeedbackWidget[0];
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ private async void InitBtn_Click(object sender, RoutedEventArgs e)
+ {
+ try {
+ CountlySDK.Countly.Halt();
+ CountlyConfig cc = new CountlyConfig {
+ serverUrl = ServerBox.Text.Trim(),
+ appKey = AppKeyBox.Text.Trim(),
+ appVersion = "1.0"
+ };
+ await CountlySDK.Countly.Instance.Init(cc);
+
+ _widgets = await CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets();
+ WidgetList.ItemsSource = _widgets.Select(w => $"{w.type} {w.name} ({w.widgetId})").ToArray();
+
+ bool rt = WebView2Runtime.IsAvailable(out string version);
+ StatusText.Text = $"Fetched {_widgets.Length} widget(s). WebView2 runtime: {(rt ? version : "NOT INSTALLED")}";
+ } catch (Exception ex) {
+ StatusText.Text = "Init/fetch failed: " + ex.Message;
+ }
+ }
+
+ private void ShowBtn_Click(object sender, RoutedEventArgs e)
+ {
+ int i = WidgetList.SelectedIndex;
+ if (i < 0 || i >= _widgets.Length) {
+ StatusText.Text = "Select a widget in the list first.";
+ return;
+ }
+ CountlyWebView.PresentFeedbackWidget(this, _widgets[i], () => StatusText.Text = "Widget closed.");
+ }
+ }
+}
From a73b05a58589d2366678228c9dafda099e6d2147 Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 19:36:16 +0900
Subject: [PATCH 2/7] 35 issue
---
.../Entities/CountlyFeedbackWidget.cs | 14 +++++++++++++-
.../countlyCommon/Modules/ModuleFeedback.cs | 9 +++++----
2 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
index bfb24113..98e2a0d2 100644
--- a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
+++ b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
@@ -27,7 +27,7 @@ public static CountlyFeedbackWidget[] ParseAvailableWidgets(string responseText)
foreach (JToken item in arr) {
string typeStr = (string)item["type"];
if (typeStr == null) { continue; }
- if (!System.Enum.TryParse(typeStr, out FeedbackWidgetType type)) { continue; }
+ if (!TryParseWidgetType(typeStr, out FeedbackWidgetType type)) { continue; }
CountlyFeedbackWidget w = new CountlyFeedbackWidget {
widgetId = (string)item["_id"],
@@ -43,5 +43,17 @@ public static CountlyFeedbackWidget[] ParseAvailableWidgets(string responseText)
return result.ToArray();
}
+
+ // net35-safe replacement for Enum.TryParse (which is .NET 4.0+). The enum member names
+ // are the exact server type strings.
+ private static bool TryParseWidgetType(string value, out FeedbackWidgetType type)
+ {
+ switch (value) {
+ case "survey": type = FeedbackWidgetType.survey; return true;
+ case "nps": type = FeedbackWidgetType.nps; return true;
+ case "rating": type = FeedbackWidgetType.rating; return true;
+ default: type = default(FeedbackWidgetType); return false;
+ }
+ }
}
}
diff --git a/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
index c248534c..534cea2f 100644
--- a/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
+++ b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
@@ -113,10 +113,11 @@ public async Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widge
internal class MockFeedback : Feedback
{
- public Task GetAvailableFeedbackWidgets() { return Task.FromResult(new CountlyFeedbackWidget[0]); }
- public Task GetFeedbackWidgetData(CountlyFeedbackWidget widget) { return Task.FromResult(null); }
- public Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult) { return Task.FromResult(0); }
- public Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget) { return Task.FromResult(null); }
+ // async-empty (no Task.FromResult, which is unavailable on net35) — mirrors MockRemoteConfig.
+ public async Task GetAvailableFeedbackWidgets() { return new CountlyFeedbackWidget[0]; }
+ public async Task GetFeedbackWidgetData(CountlyFeedbackWidget widget) { return null; }
+ public async Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult) { }
+ public async Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget) { return null; }
}
///
From 86ef33050b2164363f54b8cbe01c0563079e8b32 Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 19:40:41 +0900
Subject: [PATCH 3/7] ci
---
.github/workflows/ci.yml | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..61bb6eea
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,25 @@
+name: CI
+
+on:
+ pull_request:
+ branches:
+ - staging
+
+jobs:
+ feedback-ui-tests:
+ name: Feedback + UI unit tests (net8.0-windows)
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Run unit tests
+ run: >
+ dotnet test ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj
+ -c Release
+ -p:SignAssembly=false
+ -p:DelaySign=false
+ -p:GenerateDocumentationFile=false
From cca5eda490d412000936775df0f4079cbcb484ad Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Sat, 4 Jul 2026 22:24:00 +0900
Subject: [PATCH 4/7] content
---
.github/workflows/ci.yml | 17 ++
CHANGELOG.md | 8 +
countlyCommon/TestingRelated/ConsentTests.cs | 9 +-
countlyCommon/TestingRelated/DeviceIdTests.cs | 3 +-
countlyCommon/TestingRelated/TestHelper.cs | 8 +-
.../TestingRelated/UserDetailsTests.cs | 8 +-
countlyCommon/countlyCommon/CountlyBase.cs | 92 ++++++++---
.../countlyCommon/Entities/ContentModels.cs | 47 ++++++
.../Entities/CountlyConfigBase.cs | 13 ++
.../Helpers/ContentRequestBuilder.cs | 43 +++++
.../countlyCommon/Helpers/RequestHelper.cs | 3 +
.../countlyCommon/Modules/ModuleContent.cs | 152 ++++++++++++++++++
net35/Countly/Countly.csproj | 6 +
net45/Countly/Countly.csproj | 6 +
netstd/Countly/Countly.cs | 4 +-
netstd/Countly/Countly.csproj | 5 +
netstd/CountlyTest_461/CountlyTest_461.csproj | 2 +-
.../ContentConfigTests.cs | 19 +++
.../ContentEntryPointTests.cs | 27 ++++
.../ContentParserTests.cs | 31 ++++
.../ContentRequestBuilderTests.cs | 32 ++++
.../ModuleContentAccessorTests.cs | 50 ++++++
.../ModuleContentZoneTests.cs | 74 +++++++++
ui/Countly.UI.WebView2/CHANGELOG.md | 1 +
.../CountlyWebView.Content.cs | 28 ++++
.../CountlyWebView.WinForms.cs | 11 +-
ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs | 11 +-
.../WebView2ContentDisplay.cs | 86 ++++++++++
ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml | 1 +
ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs | 8 +-
30 files changed, 765 insertions(+), 40 deletions(-)
create mode 100644 countlyCommon/countlyCommon/Entities/ContentModels.cs
create mode 100644 countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs
create mode 100644 countlyCommon/countlyCommon/Modules/ModuleContent.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ContentParserTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs
create mode 100644 ui/Countly.UI.WebView2/CountlyWebView.Content.cs
create mode 100644 ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 61bb6eea..9910f564 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,3 +23,20 @@ jobs:
-p:SignAssembly=false
-p:DelaySign=false
-p:GenerateDocumentationFile=false
+
+ core-build:
+ name: Build core (net35 / net45 / netstd)
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: microsoft/setup-msbuild@v2
+
+ - name: Restore + build core targets
+ shell: pwsh
+ run: |
+ nuget restore net35/Countly.sln
+ nuget restore net45/Countly.sln
+ msbuild net35/Countly/Countly.csproj /t:Build /p:Configuration=Release /p:SignAssembly=false /p:DelaySign=false /p:GenerateDocumentationFile=false /v:minimal
+ msbuild net45/Countly/Countly.csproj /t:Build /p:Configuration=Release /p:SignAssembly=false /p:DelaySign=false /p:GenerateDocumentationFile=false /v:minimal
+ dotnet build netstd/Countly/Countly.csproj -c Release -p:SignAssembly=false -p:DelaySign=false -p:GenerateDocumentationFile=false
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06064d79..6f9157b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,14 @@
* "ConstructFeedbackWidgetUrl" for building a widget's display URL
* This feature uses "Feedback" consent (and "StarRating" consent for rating widgets).
* Needs "Countly.UI.WebView2" for displaying feedback widgets on WPF and WinForms.
+* Added support for the experimental Content feature accessible through the "Countly.Instance.Content()" interface:
+ * "EnterContentZone" / "ExitContentZone" for starting and stopping periodic content fetching
+ * "RefreshContentZone" for forcing an immediate refresh
+ * "PreviewContent" for fetching and showing a specific content by id
+ * Added "ContentZoneTimerInterval" and a global content callback to the configuration object
+ * This feature uses "Content" consent.
+ * Needs "Countly.UI.WebView2" for displaying content on WPF.
+* Fixed a bug where the request queue was not processed when automatic user property saving ("autoSendUserDetails") was disabled, so queued requests (for example from a manual session update) were never uploaded and could cause the SDK to loop indefinitely.
## 25.4.3
* ! Minor breaking change ! User properties will now be automatically saved under the following conditions:
diff --git a/countlyCommon/TestingRelated/ConsentTests.cs b/countlyCommon/TestingRelated/ConsentTests.cs
index 862efdbc..e681d751 100644
--- a/countlyCommon/TestingRelated/ConsentTests.cs
+++ b/countlyCommon/TestingRelated/ConsentTests.cs
@@ -195,7 +195,7 @@ public void TestConsentRequest()
Assert.False(string.IsNullOrEmpty(collection.Get("t")));
JObject consentObj = JObject.Parse(collection.Get("consent"));
- Assert.Equal(10, consentObj.Count);
+ Assert.Equal(11, consentObj.Count);
Assert.False(consentObj.GetValue("push").ToObject());
Assert.True(consentObj.GetValue("users").ToObject());
Assert.False(consentObj.GetValue("views").ToObject());
@@ -206,6 +206,7 @@ public void TestConsentRequest()
Assert.False(consentObj.GetValue("feedback").ToObject());
Assert.False(consentObj.GetValue("star-rating").ToObject());
Assert.False(consentObj.GetValue("remote-config").ToObject());
+ Assert.False(consentObj.GetValue("content").ToObject());
}
///
@@ -241,7 +242,7 @@ public void TestGiveIndividualConsents()
NameValueCollection collection = HttpUtility.ParseQueryString(request.Request);
JObject consentObj = JObject.Parse(collection.Get("consent"));
- Assert.Equal(10, consentObj.Count);
+ Assert.Equal(11, consentObj.Count);
Assert.False(consentObj.GetValue("push").ToObject());
Assert.True(consentObj.GetValue("users").ToObject());
Assert.False(consentObj.GetValue("views").ToObject());
@@ -252,6 +253,7 @@ public void TestGiveIndividualConsents()
Assert.False(consentObj.GetValue("feedback").ToObject());
Assert.False(consentObj.GetValue("star-rating").ToObject());
Assert.False(consentObj.GetValue("remote-config").ToObject());
+ Assert.False(consentObj.GetValue("content").ToObject());
Dictionary consentToRemove = new Dictionary();
consentToRemove.Add(ConsentFeatures.Crashes, false);
@@ -265,7 +267,7 @@ public void TestGiveIndividualConsents()
collection = HttpUtility.ParseQueryString(request.Request);
consentObj = JObject.Parse(collection.Get("consent"));
- Assert.Equal(10, consentObj.Count);
+ Assert.Equal(11, consentObj.Count);
Assert.False(consentObj.GetValue("push").ToObject());
Assert.True(consentObj.GetValue("users").ToObject());
Assert.False(consentObj.GetValue("views").ToObject());
@@ -276,6 +278,7 @@ public void TestGiveIndividualConsents()
Assert.False(consentObj.GetValue("feedback").ToObject());
Assert.False(consentObj.GetValue("star-rating").ToObject());
Assert.False(consentObj.GetValue("remote-config").ToObject());
+ Assert.False(consentObj.GetValue("content").ToObject());
}
}
}
diff --git a/countlyCommon/TestingRelated/DeviceIdTests.cs b/countlyCommon/TestingRelated/DeviceIdTests.cs
index ae957ae9..219e4235 100644
--- a/countlyCommon/TestingRelated/DeviceIdTests.cs
+++ b/countlyCommon/TestingRelated/DeviceIdTests.cs
@@ -141,7 +141,7 @@ public async void TestConsent_ChangeDeviceIdWithoutMerge()
collection = HttpUtility.ParseQueryString(request.Request);
JObject consentObj = JObject.Parse(collection.Get("consent"));
- Assert.Equal(10, consentObj.Count);
+ Assert.Equal(11, consentObj.Count);
Assert.False(consentObj.GetValue("push").ToObject());
Assert.True(consentObj.GetValue("users").ToObject());
Assert.False(consentObj.GetValue("views").ToObject());
@@ -152,6 +152,7 @@ public async void TestConsent_ChangeDeviceIdWithoutMerge()
Assert.False(consentObj.GetValue("feedback").ToObject());
Assert.False(consentObj.GetValue("star-rating").ToObject());
Assert.False(consentObj.GetValue("remote-config").ToObject());
+ Assert.False(consentObj.GetValue("content").ToObject());
type = collection.Get("t");
Assert.False(string.IsNullOrEmpty(type));
diff --git a/countlyCommon/TestingRelated/TestHelper.cs b/countlyCommon/TestingRelated/TestHelper.cs
index a708bb9c..19c9cf00 100644
--- a/countlyCommon/TestingRelated/TestHelper.cs
+++ b/countlyCommon/TestingRelated/TestHelper.cs
@@ -528,12 +528,16 @@ internal static void ValidateRequestInQueue(string deviceId, string appKey, IDic
}
}
- internal static void ValidateRequest(Dictionary request, IDictionary paramaters)
+ internal static void ValidateRequest(Dictionary request, IDictionary paramaters, IDictionary> customValidators = null)
{
ValidateBaseParams(request, DEVICE_ID, APP_KEY, 0);
Assert.Equal(11 + paramaters.Count, request.Count); // + rr
foreach (KeyValuePair item in paramaters) {
- Assert.Equal(item.Value.ToString(), request[item.Key]);
+ if (customValidators != null && customValidators.ContainsKey(item.Key)) {
+ customValidators[item.Key].Invoke(request[item.Key], item.Value);
+ } else {
+ Assert.Equal(item.Value.ToString(), request[item.Key]);
+ }
}
}
diff --git a/countlyCommon/TestingRelated/UserDetailsTests.cs b/countlyCommon/TestingRelated/UserDetailsTests.cs
index 897153ea..356c8578 100644
--- a/countlyCommon/TestingRelated/UserDetailsTests.cs
+++ b/countlyCommon/TestingRelated/UserDetailsTests.cs
@@ -232,7 +232,13 @@ public void SetUserDetails_SessionEventsTriggers()
TestHelper.ValidateRequest(server.Requests[3].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John")));
Assert.Contains("Test1", server.Requests[4].Params["events"]);
TestHelper.ValidateRequest(server.Requests[5].Params, TestHelper.Dict("user_details", TestHelper.Json("email", "Doe@doe.com")));
- TestHelper.ValidateRequest(server.Requests[6].Params, TestHelper.Dict("end_session", "1", "session_duration", 3));
+ // session_duration here is wall-clock derived (~2.4s of sleeps), so it rounds to 2 or 3
+ // depending on machine/CI speed. Validate its presence with a tolerant range instead of
+ // an exact value to avoid timing flakiness.
+ TestHelper.ValidateRequest(server.Requests[6].Params, TestHelper.Dict("end_session", "1", "session_duration", 3),
+ new Dictionary> {
+ { "session_duration", (actual, expected) => Assert.True(int.Parse(actual) >= 2 && int.Parse(actual) <= 4, "session_duration was " + actual) }
+ });
server.Dispose();
}
diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs
index 4747d3d5..83bd4fd8 100644
--- a/countlyCommon/countlyCommon/CountlyBase.cs
+++ b/countlyCommon/countlyCommon/CountlyBase.cs
@@ -61,6 +61,7 @@ public enum LogLevel { VERBOSE, DEBUG, INFO, WARNING, ERROR };
internal ModuleBackendMode moduleBackendMode;
internal ModuleRemoteConfig moduleRemoteConfig;
internal ModuleFeedback moduleFeedback;
+ internal ModuleContent moduleContent;
public abstract string sdkName();
@@ -200,7 +201,7 @@ internal enum ConsentChangedAction
}
public enum ConsentFeatures
{
- Sessions, Events, Location, Crashes, Users, Views, Push, Feedback, StarRating, RemoteConfig
+ Sessions, Events, Location, Crashes, Users, Views, Push, Feedback, StarRating, RemoteConfig, Content
};
internal abstract Metrics GetSessionMetrics();
@@ -295,9 +296,11 @@ internal async Task Upload()
{
UtilityHelper.CountlyLogging("[CountlyBase] Calling 'Upload'");
bool success = false;
- bool shouldContinue = false;
- do {
+ // Iterative drain with a no-progress guard.The guard stops once a pass makes no progress, so no stuck queue can loop forever.
+ int previousPending = int.MaxValue;
+
+ while (true) {
if (deferUpload) {
return true;
}
@@ -323,33 +326,38 @@ internal async Task Upload()
success = await UploadUserDetails();
}
- if (success && Configuration.autoSendUserDetails) {
+ // Drain the request queue in BOTH modes.
+ if (success) {
success = await UploadStoredRequests();
}
- if (success && !uploadInProgress) {
- int sC, exC, evC, rC;
- bool isChanged;
+ if (!success || uploadInProgress) {
+ UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, in progress or unsuccessful");
+ break;
+ }
- lock (sync) {
- sC = Sessions.Count;
- exC = Exceptions.Count;
- evC = Events.Count;
- rC = StoredRequests.Count;
- isChanged = !Configuration.autoSendUserDetails && UserDetails.isChanged; // if the auto flushing UPs used, this should not work at all
- }
+ int sC, exC, evC, rC;
+ bool isChanged;
- UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, " + sC + " " + exC + " " + evC + " " + rC + " " + isChanged);
+ lock (sync) {
+ sC = Sessions.Count;
+ exC = Exceptions.Count;
+ evC = Events.Count;
+ rC = StoredRequests.Count;
+ isChanged = !Configuration.autoSendUserDetails && UserDetails.isChanged && IsConsentGiven(ConsentFeatures.Users);
+ }
- if (sC > 0 || exC > 0 || evC > 0 || rC > 0 || isChanged) {
- //work still needs to be done
- return await Upload();
- }
- } else {
- UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, in progress");
+ UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, " + sC + " " + exC + " " + evC + " " + rC + " " + isChanged);
+
+ int pending = sC + exC + evC + rC + (isChanged ? 1 : 0);
+ if (pending == 0 || pending >= previousPending) {
+ // Everything drained, or a pass made no progress (an item can't be flushed) -> stop
+ // instead of looping forever; leftovers retry on the next Upload() trigger.
+ break;
}
- } while (success && shouldContinue);
+ previousPending = pending;
+ }
return success;
}
@@ -1212,6 +1220,8 @@ internal async Task HaltInternal(bool clearStorage = true)
moduleBackendMode = null;
moduleRemoteConfig = null;
moduleFeedback = null;
+ if (moduleContent != null) { moduleContent.ExitContentZone(); } // stop the poll timer
+ moduleContent = null;
}
if (clearStorage) {
await ClearStorage();
@@ -1514,6 +1524,7 @@ protected async Task InitBase(CountlyConfig config)
moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl);
moduleFeedback = new ModuleFeedback(requestHelper, ServerUrl);
+ moduleContent = new ModuleContent(requestHelper, ServerUrl);
UtilityHelper.CountlyLogging("[CountlyBase] Finished 'InitBase'");
await OnInitComplete();
@@ -1863,6 +1874,10 @@ private async Task ActionsOnConsentChanges(Dictionary upd
await RemoteConfig().DownloadKeys();
}
break;
+ case ConsentFeatures.Content:
+ //content consent removed -> stop the polling timer (and invalidate any in-flight fetch)
+ if (!isGiven && moduleContent != null) { moduleContent.ExitContentZone(); }
+ break;
}
}
}
@@ -1999,8 +2014,8 @@ public BackendMode BackendMode()
///
public RemoteConfig RemoteConfig()
{
- if (Configuration.backendMode) {
- UtilityHelper.CountlyLogging("[CountlyBase] RemoteConfig, backend mode is enabled, will omit this call");
+ if (Configuration == null || Configuration.backendMode) {
+ UtilityHelper.CountlyLogging("[CountlyBase] RemoteConfig, backend mode is enabled or SDK not initialized, will omit this call");
return new MockRemoteConfig();
}
@@ -2022,8 +2037,8 @@ public RemoteConfig RemoteConfig()
///
public Feedback Feedback()
{
- if (Configuration.backendMode) {
- UtilityHelper.CountlyLogging("[CountlyBase] Feedback, backend mode is enabled, will omit this call");
+ if (Configuration == null || Configuration.backendMode) {
+ UtilityHelper.CountlyLogging("[CountlyBase] Feedback, backend mode is enabled or SDK not initialized, will omit this call");
return new MockFeedback();
}
@@ -2037,5 +2052,30 @@ public Feedback Feedback()
return moduleFeedback;
}
+
+ /// Registers the UI content-display bridge (ungated; no-op if uninitialized).
+ public void SetContentDisplay(IContentDisplay display)
+ {
+ if (moduleContent != null) { moduleContent.display = display; }
+ }
+
+ ///
+ /// Returns the Content interface (experimental). A no-op is
+ /// returned when backend mode is enabled, the module is unavailable, or Content consent
+ /// is not given.
+ ///
+ public Content Content()
+ {
+ if (Configuration == null || Configuration.backendMode) {
+ return new MockContent();
+ }
+ if (moduleContent == null) {
+ return new MockContent();
+ }
+ if (!IsConsentGiven(ConsentFeatures.Content)) {
+ return new MockContent();
+ }
+ return moduleContent;
+ }
}
}
diff --git a/countlyCommon/countlyCommon/Entities/ContentModels.cs b/countlyCommon/countlyCommon/Entities/ContentModels.cs
new file mode 100644
index 00000000..0cb47d49
--- /dev/null
+++ b/countlyCommon/countlyCommon/Entities/ContentModels.cs
@@ -0,0 +1,47 @@
+using Newtonsoft.Json.Linq;
+
+namespace CountlySDK.CountlyCommon
+{
+ public class ContentPlacement { public int X, Y, W, H; }
+
+ public class ContentScreen { public int Width, Height; }
+
+ public class ContentData
+ {
+ public string Url;
+ public ContentPlacement Portrait;
+ public ContentPlacement Landscape;
+ }
+
+ public static class ContentParser
+ {
+ /// Parses a /o/sdk/content response; returns null if it has no usable content.
+ public static ContentData ParseContentResponse(string json)
+ {
+ if (string.IsNullOrEmpty(json)) { return null; }
+ try {
+ JObject root = JObject.Parse(json);
+ string url = (string)root["html"];
+ JObject geo = root["geo"] as JObject;
+ if (string.IsNullOrEmpty(url) || geo == null) { return null; }
+
+ ContentPlacement p = ToPlacement(geo["p"]);
+ ContentPlacement l = ToPlacement(geo["l"]);
+ if (p == null && l == null) { return null; }
+
+ return new ContentData { Url = url, Portrait = p, Landscape = l };
+ } catch {
+ return null;
+ }
+ }
+
+ private static ContentPlacement ToPlacement(JToken t)
+ {
+ if (!(t is JObject o)) { return null; }
+ return new ContentPlacement {
+ X = (int?)o["x"] ?? 0, Y = (int?)o["y"] ?? 0,
+ W = (int?)o["w"] ?? 0, H = (int?)o["h"] ?? 0
+ };
+ }
+ }
+}
diff --git a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs
index e63a9917..93f85520 100644
--- a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs
+++ b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs
@@ -51,6 +51,19 @@ public class CountlyConfigBase
///
public int sessionUpdateInterval = 60;
+ private int _contentZoneTimerInterval = 30;
+
+ ///
+ /// Content zone poll interval in seconds. Values <= 15 are ignored. Default 30. (Experimental.)
+ ///
+ public int ContentZoneTimerInterval {
+ get { return _contentZoneTimerInterval; }
+ set { if (value > 15) { _contentZoneTimerInterval = value; } }
+ }
+
+ /// Optional callback invoked when a shown content item is closed. (Experimental.)
+ public System.Action GlobalContentCallback { get; set; }
+
//
/// Maximum size of all string keys
///
diff --git a/countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs b/countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs
new file mode 100644
index 00000000..5dd06ec9
--- /dev/null
+++ b/countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+
+namespace CountlySDK.CountlyCommon.Helpers
+{
+ ///
+ /// Builds the content-specific query params for a /o/sdk/content fetch (base params are added
+ /// by RequestHelper.BuildRequest). Public so it can be unit-tested directly. (Experimental.)
+ ///
+ public static class ContentRequestBuilder
+ {
+ public static IDictionary BuildParams(ContentScreen screen, string[] categories,
+ string language, string deviceType, string contentId)
+ {
+ int w = screen != null ? screen.Width : 0;
+ int h = screen != null ? screen.Height : 0;
+ // Desktop reports the same rect for both orientations.
+ string resolution = "{\"l\":{\"w\":" + w + ",\"h\":" + h + "},\"p\":{\"w\":" + w + ",\"h\":" + h + "}}";
+
+ IDictionary p = new Dictionary {
+ { "method", "queue" },
+ { "resolution", resolution },
+ { "category", CategoryList(categories) },
+ { "la", language ?? "" },
+ { "dt", deviceType ?? "desktop" }
+ };
+ if (!string.IsNullOrEmpty(contentId)) {
+ p.Add("content_id", contentId);
+ p.Add("preview", "true");
+ }
+ return p;
+ }
+
+ private static string CategoryList(string[] categories)
+ {
+ if (categories == null || categories.Length == 0) { return "[]"; }
+ string[] escaped = new string[categories.Length];
+ for (int i = 0; i < categories.Length; i++) {
+ escaped[i] = categories[i] == null ? "" : System.Uri.EscapeDataString(categories[i]);
+ }
+ return "[" + string.Join(", ", escaped) + "]";
+ }
+ }
+}
diff --git a/countlyCommon/countlyCommon/Helpers/RequestHelper.cs b/countlyCommon/countlyCommon/Helpers/RequestHelper.cs
index 8f88ebb1..fb565016 100644
--- a/countlyCommon/countlyCommon/Helpers/RequestHelper.cs
+++ b/countlyCommon/countlyCommon/Helpers/RequestHelper.cs
@@ -101,6 +101,9 @@ internal string CreateConsentUpdateRequest(Dictionary upd
case ConsentFeatures.RemoteConfig:
consentChanges += "\"remote-config\":" + (value ? "true" : "false");
break;
+ case ConsentFeatures.Content:
+ consentChanges += "\"content\":" + (value ? "true" : "false");
+ break;
default:
consentChanges += "\"unknown\":false";
break;
diff --git a/countlyCommon/countlyCommon/Modules/ModuleContent.cs b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
new file mode 100644
index 00000000..c4987ecf
--- /dev/null
+++ b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using CountlySDK.CountlyCommon.Helpers;
+using CountlySDK.CountlyCommon.Server.Responses;
+using CountlySDK.Helpers;
+using static CountlySDK.CountlyCommon.CountlyBase;
+
+namespace CountlySDK.CountlyCommon
+{
+ internal class ModuleContent : Content
+ {
+ private readonly RequestHelper requestHelper;
+ private readonly string ServerUrl;
+ internal IContentDisplay display; // set via Countly.Instance.SetContentDisplay(...)
+
+ // Fully-qualified System.Threading.Timer to avoid the netstd CountlySDK.Helpers.TimerCallback collision.
+ private System.Threading.Timer _timer;
+ private readonly object _lock = new object();
+ private bool _shouldFetch;
+ private bool _inZone;
+ private int _waitForDelay;
+ private volatile bool _fetching;
+ private int _generation; // bumped on ExitContentZone so an in-flight fetch can detect it was cancelled
+ private const int StartDelayMs = 4000;
+
+ public ModuleContent(RequestHelper requestHelper, string serverUrl)
+ {
+ this.requestHelper = requestHelper;
+ ServerUrl = serverUrl;
+ }
+
+ public void EnterContentZone(string[] categories = null)
+ {
+ if (display == null) {
+ UtilityHelper.CountlyLogging("[ModuleContent] EnterContentZone, no display registered; ignoring", LogLevel.WARNING);
+ return;
+ }
+ lock (_lock) {
+ if (_inZone) { return; }
+ _shouldFetch = true;
+ _waitForDelay = 0;
+ int periodMs = Math.Max(1000, Countly.Instance.Configuration.ContentZoneTimerInterval * 1000);
+ if (_timer == null) {
+ _timer = new System.Threading.Timer(OnTick, categories, StartDelayMs, periodMs);
+ } else {
+ _timer.Change(StartDelayMs, periodMs);
+ }
+ }
+ }
+
+ public void ExitContentZone()
+ {
+ lock (_lock) {
+ _shouldFetch = false;
+ _inZone = false;
+ _waitForDelay = 0;
+ _generation++; // invalidate any fetch already in flight so it won't present after we exit
+ if (_timer != null) { _timer.Dispose(); _timer = null; }
+ }
+ }
+
+ public void RefreshContentZone()
+ {
+ ExitContentZone();
+ EnterContentZone(null);
+ }
+
+ public void PreviewContent(string contentId)
+ {
+ if (display == null || string.IsNullOrEmpty(contentId)) { return; }
+ lock (_lock) { if (_inZone) { return; } }
+ FetchAndPresent(new string[0], contentId); // fire-and-forget; errors handled inside
+ }
+
+ private void OnTick(object state)
+ {
+ // Stop if Content consent was revoked while the zone was active (defense in depth; the
+ // consent-change handler also calls ExitContentZone, which disposes this timer).
+ if (!Countly.Instance.IsConsentGiven(ConsentFeatures.Content)) { ExitContentZone(); return; }
+ lock (_lock) {
+ if (_waitForDelay > 0) { _waitForDelay--; return; }
+ if (!_shouldFetch || _fetching) { return; }
+ _fetching = true;
+ }
+ FetchAndPresent((string[])state, null); // fire-and-forget
+ }
+
+ private async Task FetchAndPresent(string[] categories, string contentId)
+ {
+ int gen;
+ lock (_lock) { gen = _generation; }
+ try {
+ ContentScreen screen = display.GetScreen();
+ string language = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
+ IDictionary parameters = ContentRequestBuilder.BuildParams(screen, categories, language, "desktop", contentId);
+
+ RequestResult rr = await Api.Instance.SendDirectRequest(
+ ServerUrl, await requestHelper.BuildRequest(parameters), "/o/sdk/content");
+
+ ContentData content = (rr != null && rr.responseCode == 200) ? ContentParser.ParseContentResponse(rr.responseText) : null;
+ if (content == null) { return; }
+
+ lock (_lock) {
+ // Zone was exited/refreshed/halted (or consent revoked) while this fetch was in
+ // flight -> discard the result instead of presenting stale content.
+ if (gen != _generation) { return; }
+ _shouldFetch = false; _inZone = true;
+ }
+ display.Present(content.Portrait, content.Landscape, content.Url, OnContentClosed);
+ } catch (Exception ex) {
+ UtilityHelper.CountlyLogging("[ModuleContent] FetchAndPresent failed: " + ex.Message, LogLevel.ERROR);
+ } finally {
+ _fetching = false;
+ }
+ }
+
+ private void OnContentClosed()
+ {
+ lock (_lock) { _waitForDelay = 2; _shouldFetch = true; _inZone = false; }
+ Action cb = Countly.Instance.Configuration != null ? Countly.Instance.Configuration.GlobalContentCallback : null;
+ if (cb != null) { cb(); }
+ }
+ }
+
+ internal class MockContent : Content
+ {
+ public void EnterContentZone(string[] categories = null) { }
+ public void ExitContentZone() { }
+ public void RefreshContentZone() { }
+ public void PreviewContent(string contentId) { }
+ }
+
+ /// Retrieves and displays Countly content (experimental).
+ public interface Content
+ {
+ void EnterContentZone(string[] categories = null);
+ void ExitContentZone();
+ void RefreshContentZone();
+ void PreviewContent(string contentId);
+ }
+
+ ///
+ /// Bridge the UI package implements so the UI-less core can size the fetch to the screen and
+ /// display server-placed content. Registered via Countly.Instance.SetContentDisplay(...).
+ ///
+ public interface IContentDisplay
+ {
+ ContentScreen GetScreen();
+ void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed);
+ }
+}
diff --git a/net35/Countly/Countly.csproj b/net35/Countly/Countly.csproj
index c72b0008..b6ccd80a 100644
--- a/net35/Countly/Countly.csproj
+++ b/net35/Countly/Countly.csproj
@@ -81,6 +81,9 @@
Entities\CountlyFeedbackWidget.cs
+
+ Entities\ContentModels.cs
+
Entities\CountlyUserDetails.cs
@@ -132,6 +135,9 @@
Helpers\WidgetUrlBuilder.cs
+
+ Helpers\ContentRequestBuilder.cs
+
Helpers\StorageBase.cs
diff --git a/net45/Countly/Countly.csproj b/net45/Countly/Countly.csproj
index 3b037462..136a9f17 100644
--- a/net45/Countly/Countly.csproj
+++ b/net45/Countly/Countly.csproj
@@ -81,6 +81,9 @@
Entities\CountlyFeedbackWidget.cs
+
+ Entities\ContentModels.cs
+
Entities\CountlyUserDetails.cs
@@ -132,6 +135,9 @@
Helpers\WidgetUrlBuilder.cs
+
+ Helpers\ContentRequestBuilder.cs
+
Helpers\StorageBase.cs
diff --git a/netstd/Countly/Countly.cs b/netstd/Countly/Countly.cs
index d2508195..4fd516ce 100644
--- a/netstd/Countly/Countly.cs
+++ b/netstd/Countly/Countly.cs
@@ -29,7 +29,9 @@ THE SOFTWARE.
using CountlySDK.Entities;
using CountlySDK.Entities.EntityBase;
using CountlySDK.Helpers;
-//[assembly: InternalsVisibleTo("CountlyTest_461")]
+#if COUNTLY_TESTABLE
+[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CountlyTest_461")]
+#endif
//[assembly: InternalsVisibleTo("CountlySampleUWP")]
namespace CountlySDK
diff --git a/netstd/Countly/Countly.csproj b/netstd/Countly/Countly.csproj
index 5887a407..517fff35 100644
--- a/netstd/Countly/Countly.csproj
+++ b/netstd/Countly/Countly.csproj
@@ -23,6 +23,9 @@
embedded
+
+ $(DefineConstants);COUNTLY_TESTABLE
+
@@ -30,6 +33,7 @@
+
@@ -49,6 +53,7 @@
+
diff --git a/netstd/CountlyTest_461/CountlyTest_461.csproj b/netstd/CountlyTest_461/CountlyTest_461.csproj
index ed577f3b..f8cd36c4 100644
--- a/netstd/CountlyTest_461/CountlyTest_461.csproj
+++ b/netstd/CountlyTest_461/CountlyTest_461.csproj
@@ -11,7 +11,7 @@
Properties
CountlyTest_461
CountlyTest_461
- v4.6.1
+ v4.6.2
512
true
diff --git a/ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs b/ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs
new file mode 100644
index 00000000..b9f50ef6
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs
@@ -0,0 +1,19 @@
+using CountlySDK.Entities;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ public class ContentConfigTests
+ {
+ [Fact]
+ public void ZoneTimerInterval_DefaultsTo30_AndRejectsTooSmall()
+ {
+ CountlyConfig cc = new CountlyConfig();
+ Assert.Equal(30, cc.ContentZoneTimerInterval);
+ cc.ContentZoneTimerInterval = 10; // <=15 ignored
+ Assert.Equal(30, cc.ContentZoneTimerInterval);
+ cc.ContentZoneTimerInterval = 45;
+ Assert.Equal(45, cc.ContentZoneTimerInterval);
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs b/ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs
new file mode 100644
index 00000000..83ea923b
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs
@@ -0,0 +1,27 @@
+using System;
+using CountlySDK.CountlyCommon;
+using CountlySDK.UI;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ // The live overlay needs a UI thread + WebView2 runtime, so it's exercised via the demo.
+ // Here we assert the entry points exist and the bridge type implements IContentDisplay.
+ public class ContentEntryPointTests
+ {
+ [Fact]
+ public void EnableDisableContentZone_SignaturesExist()
+ {
+ Assert.NotNull(typeof(CountlyWebView).GetMethod("EnableContentZone", new[] { typeof(string[]) }));
+ Assert.NotNull(typeof(CountlyWebView).GetMethod("DisableContentZone", Type.EmptyTypes));
+ }
+
+ [Fact]
+ public void WebView2ContentDisplay_ImplementsBridge()
+ {
+ Type t = typeof(CountlyWebView).Assembly.GetType("CountlySDK.UI.WebView2ContentDisplay");
+ Assert.NotNull(t);
+ Assert.True(typeof(IContentDisplay).IsAssignableFrom(t));
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/ContentParserTests.cs b/ui/Countly.UI.WebView2.Tests/ContentParserTests.cs
new file mode 100644
index 00000000..1bcaf052
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/ContentParserTests.cs
@@ -0,0 +1,31 @@
+using CountlySDK.CountlyCommon;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ public class ContentParserTests
+ {
+ [Fact]
+ public void ParsesGeoAndHtmlUrl()
+ {
+ string json = "{\"geo\":{\"p\":{\"x\":1,\"y\":2,\"w\":300,\"h\":400},\"l\":{\"x\":5,\"y\":6,\"w\":700,\"h\":500}},\"html\":\"https://s/content/abc\"}";
+ ContentData c = ContentParser.ParseContentResponse(json);
+ Assert.NotNull(c);
+ Assert.Equal("https://s/content/abc", c.Url);
+ Assert.Equal(300, c.Portrait.W);
+ Assert.Equal(400, c.Portrait.H);
+ Assert.Equal(700, c.Landscape.W);
+ Assert.Equal(6, c.Landscape.Y);
+ }
+
+ [Fact]
+ public void InvalidOrEmpty_ReturnsNull()
+ {
+ Assert.Null(ContentParser.ParseContentResponse(""));
+ Assert.Null(ContentParser.ParseContentResponse("{}"));
+ Assert.Null(ContentParser.ParseContentResponse("{\"geo\":{},\"html\":\"u\"}"));
+ Assert.Null(ContentParser.ParseContentResponse("{\"jsonArray\":[]}"));
+ Assert.Null(ContentParser.ParseContentResponse("not json"));
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs b/ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs
new file mode 100644
index 00000000..166895b3
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using CountlySDK.CountlyCommon;
+using CountlySDK.CountlyCommon.Helpers;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ public class ContentRequestBuilderTests
+ {
+ [Fact]
+ public void BuildParams_IncludesResolutionMethodAndLocale()
+ {
+ var screen = new ContentScreen { Width = 1920, Height = 1080 };
+ IDictionary p = ContentRequestBuilder.BuildParams(screen, new[] { "cat1" }, "en", "desktop", null);
+ Assert.Equal("queue", p["method"]);
+ Assert.Equal("en", p["la"]);
+ Assert.Equal("desktop", p["dt"]);
+ Assert.Contains("1920", (string)p["resolution"]);
+ Assert.Contains("\"w\"", (string)p["resolution"]);
+ Assert.False(p.ContainsKey("content_id"));
+ }
+
+ [Fact]
+ public void BuildParams_Preview_AddsContentIdAndPreview()
+ {
+ var screen = new ContentScreen { Width = 800, Height = 600 };
+ IDictionary p = ContentRequestBuilder.BuildParams(screen, null, "en", "desktop", "cid1");
+ Assert.Equal("cid1", p["content_id"]);
+ Assert.Equal("true", p["preview"]);
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs
new file mode 100644
index 00000000..f65eda06
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs
@@ -0,0 +1,50 @@
+using CountlySDK.Entities;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ // Runtime verification of the Content() accessor wiring. 'Countly' is fully qualified because
+ // the test's 'Countly.UI.WebView2.Tests' namespace shadows the CountlySDK.Countly class.
+ public class ModuleContentAccessorTests
+ {
+ [Fact]
+ public void Content_ReturnsMock_WhenConsentRequiredAndNotGiven()
+ {
+ CountlySDK.Countly.Halt();
+ CountlyConfig cc = new CountlyConfig {
+ serverUrl = "https://no-server.count.ly",
+ appKey = "APP_KEY",
+ appVersion = "1.0",
+ consentRequired = true
+ };
+ CountlySDK.Countly.Instance.Init(cc).Wait();
+
+ // No Content consent -> MockContent -> operations are safe no-ops.
+ CountlySDK.Countly.Instance.Content().EnterContentZone();
+ CountlySDK.Countly.Instance.Content().ExitContentZone();
+ CountlySDK.Countly.Instance.Content().RefreshContentZone();
+ CountlySDK.Countly.Instance.Content().PreviewContent("cid");
+
+ CountlySDK.Countly.Halt();
+ }
+
+ [Fact]
+ public void Accessors_WhenNotInitialized_ReturnMocksWithoutThrowing()
+ {
+ // Repro of the reported crash: calling an accessor before Init (Configuration == null)
+ // NRE'd at CountlyBase.Content() 'if (Configuration.backendMode)'. Configuration is
+ // internal and never nulled by Halt, so force the not-initialized state via reflection.
+ CountlySDK.Countly.Halt();
+ System.Reflection.FieldInfo cfg = typeof(CountlySDK.CountlyCommon.CountlyBase).GetField(
+ "Configuration", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ Assert.NotNull(cfg);
+ cfg.SetValue(CountlySDK.Countly.Instance, null);
+
+ // Each accessor must return a no-op mock, not throw NullReferenceException.
+ Assert.NotNull(CountlySDK.Countly.Instance.Content());
+ CountlySDK.Countly.Instance.Content().EnterContentZone();
+ Assert.NotNull(CountlySDK.Countly.Instance.Feedback());
+ Assert.NotNull(CountlySDK.Countly.Instance.RemoteConfig());
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs
new file mode 100644
index 00000000..21f20cca
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Threading;
+using CountlySDK.CountlyCommon;
+using CountlySDK.Entities;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ internal sealed class FakeContentDisplay : IContentDisplay
+ {
+ public ContentScreen Screen = new ContentScreen { Width = 1000, Height = 800 };
+ public volatile string PresentedUrl;
+ public volatile ContentPlacement PresentedPortrait;
+ public Action LastOnClosed;
+
+ public ContentScreen GetScreen() { return Screen; }
+ public void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed)
+ {
+ PresentedPortrait = portrait;
+ PresentedUrl = url;
+ LastOnClosed = onClosed;
+ }
+ }
+
+ public class ModuleContentZoneTests
+ {
+ [Fact]
+ public void EnterContentZone_FetchesAndPresents()
+ {
+ using LocalMockServer server = new LocalMockServer((rawUrl, body) =>
+ (rawUrl + body).Contains("method=queue")
+ ? "{\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":320,\"h\":480},\"l\":{\"x\":0,\"y\":0,\"w\":800,\"h\":600}},\"html\":\"https://s/content/1\"}"
+ : null);
+
+ CountlySDK.Countly.Halt();
+ CountlyConfig cc = new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" };
+ cc.ContentZoneTimerInterval = 16;
+ CountlySDK.Countly.Instance.Init(cc).Wait();
+
+ FakeContentDisplay display = new FakeContentDisplay();
+ CountlySDK.Countly.Instance.SetContentDisplay(display);
+ CountlySDK.Countly.Instance.Content().EnterContentZone();
+
+ for (int i = 0; i < 120 && display.PresentedUrl == null; i++) { Thread.Sleep(100); }
+
+ Assert.Equal("https://s/content/1", display.PresentedUrl);
+ Assert.Equal(320, display.PresentedPortrait.W);
+
+ CountlySDK.Countly.Instance.Content().ExitContentZone();
+ CountlySDK.Countly.Halt();
+ }
+
+ [Fact]
+ public void PreviewContent_OneShotFetchesAndPresents()
+ {
+ using LocalMockServer server = new LocalMockServer((rawUrl, body) =>
+ (rawUrl + body).Contains("preview=true")
+ ? "{\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":300,\"h\":300},\"l\":{\"x\":0,\"y\":0,\"w\":300,\"h\":300}},\"html\":\"https://s/content/preview\"}"
+ : null);
+
+ CountlySDK.Countly.Halt();
+ CountlyConfig cc = new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" };
+ CountlySDK.Countly.Instance.Init(cc).Wait();
+
+ FakeContentDisplay display = new FakeContentDisplay();
+ CountlySDK.Countly.Instance.SetContentDisplay(display);
+ CountlySDK.Countly.Instance.Content().PreviewContent("cid1");
+
+ for (int i = 0; i < 50 && display.PresentedUrl == null; i++) { Thread.Sleep(100); }
+ Assert.Equal("https://s/content/preview", display.PresentedUrl);
+ CountlySDK.Countly.Halt();
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/CHANGELOG.md b/ui/Countly.UI.WebView2/CHANGELOG.md
index a994a0b9..538a4b4e 100644
--- a/ui/Countly.UI.WebView2/CHANGELOG.md
+++ b/ui/Countly.UI.WebView2/CHANGELOG.md
@@ -4,6 +4,7 @@
* WPF overload: "PresentFeedbackWidget(System.Windows.Window owner, CountlyFeedbackWidget widget, Action onClosed = null)"
* WinForms overload: "PresentFeedbackWidget(System.Windows.Forms.IWin32Window owner, CountlyFeedbackWidget widget, Action onClosed = null)"
* Dynamically resizes the host window to the widget's requested size and auto-reports the result when the widget is closed.
+* Added "CountlyWebView.EnableContentZone()" / "DisableContentZone()" for displaying Countly content (experimental) as a borderless, top-most WebView2 overlay positioned by the server on the primary screen (WPF).
* Added "WebView2Runtime.IsAvailable(out string version)" to detect the WebView2 Evergreen Runtime; when the runtime is missing, presentation is a graceful no-op.
* Targets .NET Framework 4.6.2 and .NET 8 (Windows). Depends on the "Countly" SDK package and "Microsoft.Web.WebView2".
* Requires the WebView2 Evergreen Runtime to be installed on the end-user machine.
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Content.cs b/ui/Countly.UI.WebView2/CountlyWebView.Content.cs
new file mode 100644
index 00000000..3b618085
--- /dev/null
+++ b/ui/Countly.UI.WebView2/CountlyWebView.Content.cs
@@ -0,0 +1,28 @@
+namespace CountlySDK.UI
+{
+ public static partial class CountlyWebView
+ {
+ private static WebView2ContentDisplay _contentDisplay;
+
+ ///
+ /// Enables the Countly content zone with a WebView2 overlay on the primary screen.
+ /// Call on the UI thread. No-op if the WebView2 runtime is unavailable. (Experimental.)
+ ///
+ public static void EnableContentZone(string[] categories = null)
+ {
+ if (!WebView2Runtime.IsAvailable(out _)) {
+ System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; content zone disabled");
+ return;
+ }
+ _contentDisplay = new WebView2ContentDisplay();
+ CountlySDK.Countly.Instance.SetContentDisplay(_contentDisplay);
+ CountlySDK.Countly.Instance.Content().EnterContentZone(categories);
+ }
+
+ /// Disables the content zone (stops polling and prevents further overlays).
+ public static void DisableContentZone()
+ {
+ CountlySDK.Countly.Instance.Content().ExitContentZone();
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
index a9e44d89..283afc6e 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
@@ -36,8 +36,15 @@ public static void PresentFeedbackWidget(IWin32Window owner, CountlyFeedbackWidg
FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape: false, onClosed);
form.Load += async (s, e) => {
- await adapter.InitializeAsync();
- await presenter.StartAsync(widget);
+ try {
+ await adapter.InitializeAsync();
+ await presenter.StartAsync(widget);
+ } catch (Exception ex) {
+ // async void: a display failure must never crash the host app.
+ System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex);
+ try { form.Close(); } catch { }
+ onClosed?.Invoke();
+ }
};
form.Show(owner);
}
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
index b2a0da98..5b777d75 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
@@ -39,8 +39,15 @@ public static void PresentFeedbackWidget(Window owner, CountlyFeedbackWidget wid
FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape, onClosed);
host.Loaded += async (s, e) => {
- await adapter.InitializeAsync();
- await presenter.StartAsync(widget);
+ try {
+ await adapter.InitializeAsync();
+ await presenter.StartAsync(widget);
+ } catch (Exception ex) {
+ // async void: a display failure must never crash the host app.
+ System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex);
+ try { host.Close(); } catch { }
+ onClosed?.Invoke();
+ }
};
host.Show();
}
diff --git a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
new file mode 100644
index 00000000..d01513a2
--- /dev/null
+++ b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Windows;
+using System.Windows.Threading;
+using CountlySDK.CountlyCommon;
+using Microsoft.Web.WebView2.Wpf;
+
+namespace CountlySDK.UI
+{
+ ///
+ /// WPF implementation of the core bridge: reports the primary
+ /// screen size and shows server-placed content in a borderless, top-most, non-modal WebView2
+ /// window at the given coordinates. Constructed on the UI thread (captures its Dispatcher).
+ ///
+ internal sealed class WebView2ContentDisplay : IContentDisplay
+ {
+ private readonly Dispatcher _dispatcher;
+ private readonly int _screenW, _screenH;
+
+ public WebView2ContentDisplay()
+ {
+ _dispatcher = Dispatcher.CurrentDispatcher; // captured on the UI thread
+ _screenW = (int)SystemParameters.WorkArea.Width;
+ _screenH = (int)SystemParameters.WorkArea.Height;
+ }
+
+ public ContentScreen GetScreen()
+ {
+ return new ContentScreen { Width = _screenW, Height = _screenH };
+ }
+
+ public void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed)
+ {
+ _dispatcher.BeginInvoke(new Action(() => {
+ try {
+ ContentPlacement r = (_screenW >= _screenH ? landscape : portrait) ?? portrait ?? landscape;
+ if (r == null) { onClosed?.Invoke(); return; }
+
+ Window host = new Window {
+ WindowStyle = WindowStyle.None,
+ ResizeMode = ResizeMode.NoResize,
+ Topmost = true,
+ ShowInTaskbar = false,
+ ShowActivated = false,
+ WindowStartupLocation = WindowStartupLocation.Manual,
+ Left = r.X, Top = r.Y, Width = r.W, Height = r.H
+ };
+ WebView2 webView = new WebView2();
+ host.Content = webView;
+
+ host.Loaded += async (s, e) => {
+ try {
+ await webView.EnsureCoreWebView2Async(null);
+ webView.CoreWebView2.NavigationStarting += (s2, e2) => {
+ WidgetAction a = WidgetActionParser.Parse(e2.Uri);
+ if (!a.IsActionEvent) { return; }
+
+ if (a.HasResize) {
+ WidgetRect rect = _screenW >= _screenH ? (a.Landscape ?? a.Portrait) : (a.Portrait ?? a.Landscape);
+ if (rect != null) {
+ host.Left = rect.X; host.Top = rect.Y;
+ host.Width = rect.W; host.Height = rect.H;
+ }
+ }
+ if (a.Close) {
+ e2.Cancel = true;
+ host.Close();
+ onClosed?.Invoke();
+ }
+ };
+ webView.CoreWebView2.Navigate(url); // 'html' from the server is a URL
+ } catch (Exception ex) {
+ // async void: a display failure must never crash the host app.
+ System.Diagnostics.Debug.WriteLine("[CountlyWebView] content overlay failed: " + ex);
+ try { host.Close(); } catch { }
+ onClosed?.Invoke(); // return the module to a fetchable state so the zone is not wedged
+ }
+ };
+ host.Show();
+ } catch (Exception ex) {
+ System.Diagnostics.Debug.WriteLine("[CountlyWebView] content overlay failed to open: " + ex);
+ onClosed?.Invoke();
+ }
+ }));
+ }
+ }
+}
diff --git a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
index 3894f53f..cdaadae1 100644
--- a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
+++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
@@ -10,6 +10,7 @@
+
diff --git a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
index 209f4910..e51f5612 100644
--- a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
+++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
@@ -26,7 +26,7 @@ private async void InitBtn_Click(object sender, RoutedEventArgs e)
appVersion = "1.0"
};
await CountlySDK.Countly.Instance.Init(cc);
-
+ await CountlySDK.Countly.Instance.SessionBegin();
_widgets = await CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets();
WidgetList.ItemsSource = _widgets.Select(w => $"{w.type} {w.name} ({w.widgetId})").ToArray();
@@ -46,5 +46,11 @@ private void ShowBtn_Click(object sender, RoutedEventArgs e)
}
CountlyWebView.PresentFeedbackWidget(this, _widgets[i], () => StatusText.Text = "Widget closed.");
}
+
+ private void ContentBtn_Click(object sender, RoutedEventArgs e)
+ {
+ CountlyWebView.EnableContentZone();
+ StatusText.Text = "Content zone enabled (polling). Server must have active content for this device.";
+ }
}
}
From 0f01071ef4b4c254e5aab5e0df79194ddad42a8b Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Sun, 5 Jul 2026 00:03:08 +0900
Subject: [PATCH 5/7] codacy
---
countlyCommon/TestingRelated/UserDetailsTests.cs | 2 +-
countlyCommon/countlyCommon/Modules/ModuleContent.cs | 9 ++++++++-
ui/Countly.UI.WebView2.Tests/LocalMockServer.cs | 2 +-
ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs | 4 ++--
ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs | 4 ++--
ui/Countly.UI.WebView2/WebView2ContentDisplay.cs | 6 +++---
6 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/countlyCommon/TestingRelated/UserDetailsTests.cs b/countlyCommon/TestingRelated/UserDetailsTests.cs
index 356c8578..b2824d4c 100644
--- a/countlyCommon/TestingRelated/UserDetailsTests.cs
+++ b/countlyCommon/TestingRelated/UserDetailsTests.cs
@@ -237,7 +237,7 @@ public void SetUserDetails_SessionEventsTriggers()
// an exact value to avoid timing flakiness.
TestHelper.ValidateRequest(server.Requests[6].Params, TestHelper.Dict("end_session", "1", "session_duration", 3),
new Dictionary> {
- { "session_duration", (actual, expected) => Assert.True(int.Parse(actual) >= 2 && int.Parse(actual) <= 4, "session_duration was " + actual) }
+ { "session_duration", (actual, _) => Assert.True(int.Parse(actual) >= 2 && int.Parse(actual) <= 4, "session_duration was " + actual) }
});
server.Dispose();
diff --git a/countlyCommon/countlyCommon/Modules/ModuleContent.cs b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
index c4987ecf..7ee93f4a 100644
--- a/countlyCommon/countlyCommon/Modules/ModuleContent.cs
+++ b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
@@ -8,7 +8,7 @@
namespace CountlySDK.CountlyCommon
{
- internal class ModuleContent : Content
+ internal class ModuleContent : Content, IDisposable
{
private readonly RequestHelper requestHelper;
private readonly string ServerUrl;
@@ -60,6 +60,13 @@ public void ExitContentZone()
}
}
+ public void Dispose()
+ {
+ // Standard disposal entry point (satisfies CA1001 for the owned System.Threading.Timer);
+ // ExitContentZone stops polling and disposes the timer.
+ ExitContentZone();
+ }
+
public void RefreshContentZone()
{
ExitContentZone();
diff --git a/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
index dcfabb30..7a5d2769 100644
--- a/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
+++ b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
@@ -60,7 +60,7 @@ private static int FreePort()
return p;
}
- public void Dispose() { try { _listener.Stop(); } catch { } }
+ public void Dispose() { try { _listener.Stop(); } catch { /* listener already stopped/disposed; nothing to do */ } }
public sealed class Captured
{
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
index 283afc6e..13a61e9c 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
@@ -35,14 +35,14 @@ public static void PresentFeedbackWidget(IWin32Window owner, CountlyFeedbackWidg
WinFormsWebView2WidgetHost adapter = new WinFormsWebView2WidgetHost(webView, form);
FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape: false, onClosed);
- form.Load += async (s, e) => {
+ form.Load += async (_, e) => {
try {
await adapter.InitializeAsync();
await presenter.StartAsync(widget);
} catch (Exception ex) {
// async void: a display failure must never crash the host app.
System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex);
- try { form.Close(); } catch { }
+ try { form.Close(); } catch { /* best-effort close during teardown; not actionable */ }
onClosed?.Invoke();
}
};
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
index 5b777d75..31bbdf79 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
@@ -38,14 +38,14 @@ public static void PresentFeedbackWidget(Window owner, CountlyFeedbackWidget wid
bool isLandscape = owner != null && owner.ActualWidth >= owner.ActualHeight;
FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape, onClosed);
- host.Loaded += async (s, e) => {
+ host.Loaded += async (_, e) => {
try {
await adapter.InitializeAsync();
await presenter.StartAsync(widget);
} catch (Exception ex) {
// async void: a display failure must never crash the host app.
System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex);
- try { host.Close(); } catch { }
+ try { host.Close(); } catch { /* best-effort close during teardown; not actionable */ }
onClosed?.Invoke();
}
};
diff --git a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
index d01513a2..9d4baade 100644
--- a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
+++ b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
@@ -47,10 +47,10 @@ public void Present(ContentPlacement portrait, ContentPlacement landscape, strin
WebView2 webView = new WebView2();
host.Content = webView;
- host.Loaded += async (s, e) => {
+ host.Loaded += async (_, e) => {
try {
await webView.EnsureCoreWebView2Async(null);
- webView.CoreWebView2.NavigationStarting += (s2, e2) => {
+ webView.CoreWebView2.NavigationStarting += (_, e2) => {
WidgetAction a = WidgetActionParser.Parse(e2.Uri);
if (!a.IsActionEvent) { return; }
@@ -71,7 +71,7 @@ public void Present(ContentPlacement portrait, ContentPlacement landscape, strin
} catch (Exception ex) {
// async void: a display failure must never crash the host app.
System.Diagnostics.Debug.WriteLine("[CountlyWebView] content overlay failed: " + ex);
- try { host.Close(); } catch { }
+ try { host.Close(); } catch { /* best-effort close during teardown; not actionable */ }
onClosed?.Invoke(); // return the module to a fetchable state so the zone is not wedged
}
};
From ea02dbc6465877919691f198a6f730471500a3fb Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Fri, 17 Jul 2026 20:19:59 +0900
Subject: [PATCH 6/7] fixes
---
.gitignore | 3 +-
.../Entities/CountlyFeedbackWidget.cs | 5 +-
.../Helpers/WidgetActionParser.cs | 32 +++++
.../countlyCommon/Helpers/WidgetUrlBuilder.cs | 27 +++-
.../countlyCommon/Modules/ModuleContent.cs | 56 +++++++-
.../countlyCommon/Modules/ModuleFeedback.cs | 9 +-
.../FeedbackWidgetPresenterTests.cs | 94 ++++++++++---
.../WidgetMessageParserTests.cs | 43 ++++++
.../WidgetPlacementTests.cs | 51 +++++++
.../WidgetUrlBuilderTests.cs | 44 ++++++-
ui/Countly.UI.WebView2/CountlyWebView.Dpi.cs | 62 +++++++++
.../CountlyWebView.WinForms.cs | 70 ++++++++--
ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs | 62 ++++++---
.../FeedbackWidgetPresenter.cs | 59 ++++++---
ui/Countly.UI.WebView2/IWidgetWebHost.cs | 31 ++++-
.../WebView2ContentDisplay.cs | 124 ++++++++++++++++--
.../WebView2WidgetHost.WinForms.cs | 103 +++++++++++++--
ui/Countly.UI.WebView2/WebView2WidgetHost.cs | 123 +++++++++++++++--
ui/Countly.UI.WebView2/WidgetMessageParser.cs | 69 ++++++++++
ui/Countly.UI.WebView2/WidgetPlacement.cs | 38 ++++++
ui/CountlyFeedbackDemo.Wpf/App.xaml.cs | 22 ++++
.../CountlyFeedbackDemo.Wpf.csproj | 1 +
ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs | 7 +-
ui/CountlyFeedbackDemo.Wpf/app.manifest | 12 ++
24 files changed, 1036 insertions(+), 111 deletions(-)
create mode 100644 ui/Countly.UI.WebView2.Tests/WidgetMessageParserTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/WidgetPlacementTests.cs
create mode 100644 ui/Countly.UI.WebView2/CountlyWebView.Dpi.cs
create mode 100644 ui/Countly.UI.WebView2/WidgetMessageParser.cs
create mode 100644 ui/Countly.UI.WebView2/WidgetPlacement.cs
create mode 100644 ui/CountlyFeedbackDemo.Wpf/app.manifest
diff --git a/.gitignore b/.gitignore
index 7f061eb6..560c7682 100644
--- a/.gitignore
+++ b/.gitignore
@@ -262,4 +262,5 @@ _Pvt_Extensions
/nuget/lib/*
nuget/nuget.exe
-.DS_Store
\ No newline at end of file
+.DS_Store
+docs/
\ No newline at end of file
diff --git a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
index 98e2a0d2..d5909975 100644
--- a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
+++ b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
@@ -11,6 +11,8 @@ public class CountlyFeedbackWidget
public FeedbackWidgetType type;
public string name;
public List tags = new List();
+ // Server "wv" (widget version). Null/empty => legacy widget (no resize_me protocol).
+ public string widgetVersion;
}
public static class FeedbackWidgetParser
@@ -32,7 +34,8 @@ public static CountlyFeedbackWidget[] ParseAvailableWidgets(string responseText)
CountlyFeedbackWidget w = new CountlyFeedbackWidget {
widgetId = (string)item["_id"],
type = type,
- name = (string)item["name"]
+ name = (string)item["name"],
+ widgetVersion = (string)item["wv"]
};
if (item["tg"] is JArray tags) {
foreach (JToken t in tags) { w.tags.Add((string)t); }
diff --git a/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs b/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs
index 648b8a2d..70a1218b 100644
--- a/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs
+++ b/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs
@@ -13,6 +13,9 @@ public class WidgetAction
public bool HasResize;
public WidgetRect Portrait;
public WidgetRect Landscape;
+ // Content action-event extras (cly_x_action_event=1&action=link|event ...).
+ public string Link; // action=link: URL to open externally
+ public string EventPayload; // action=event: raw JSON array of {key, segmentation|sg}
}
public static class WidgetActionParser
@@ -39,6 +42,19 @@ public static WidgetAction Parse(string url)
action.HasResize = action.Portrait != null || action.Landscape != null;
} catch { /* ignore malformed resize payload */ }
}
+ if (q.TryGetValue("link", out string link) && !string.IsNullOrEmpty(link)) {
+ // A close carried inside the link's own query (e.g. link=https://x?close=1) is a
+ // content signal, not part of the destination: close=1 means "close after opening".
+ Dictionary linkQuery = ParseQuery(link);
+ if (!action.Close && linkQuery.TryGetValue("close", out string linkClose) && linkClose == "1") {
+ action.Close = true;
+ }
+ // Strip the close signal so we open the clean destination URL.
+ action.Link = StripParam(link, "close");
+ }
+ if (q.TryGetValue("event", out string ev) && !string.IsNullOrEmpty(ev)) {
+ action.EventPayload = ev; // raw JSON array; parsed/recorded by the content module
+ }
return action;
}
@@ -51,6 +67,22 @@ private static WidgetRect ToRect(JToken t)
};
}
+ /// Returns the URL with the given query parameter removed (base URL if none remain).
+ private static string StripParam(string url, string name)
+ {
+ int qi = url.IndexOf('?');
+ if (qi < 0) { return url; }
+ string basePart = url.Substring(0, qi);
+ List kept = new List();
+ foreach (string pair in url.Substring(qi + 1).Split('&')) {
+ int eq = pair.IndexOf('=');
+ string key = eq > 0 ? pair.Substring(0, eq) : pair;
+ if (key == name) { continue; }
+ kept.Add(pair);
+ }
+ return kept.Count > 0 ? basePart + "?" + string.Join("&", kept.ToArray()) : basePart;
+ }
+
private static Dictionary ParseQuery(string url)
{
Dictionary result = new Dictionary();
diff --git a/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs b/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs
index 6c941cbb..bb967367 100644
--- a/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs
+++ b/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs
@@ -6,8 +6,9 @@ namespace CountlySDK.CountlyCommon.Helpers
{
///
/// Builds the display URL for a feedback widget. Pure/UI-free so it can be unit-tested and
- /// reused by the WebView2 UI package. The custom={"tc":1,"rw":1,"xb":1} object opts
- /// into versioned/resizable widgets (so the widget emits resize/close action events).
+ /// reused by the WebView2 UI package. Desktop follows the web-SDK model: custom={"tc":1,"xb":1}
+ /// (no rw fullscreen flag) plus origin, so the widget renders a positioned card and
+ /// reports its rect via resize_me; the SDK reports the surface size and places the window there.
///
public static class WidgetUrlBuilder
{
@@ -16,17 +17,35 @@ public static string BuildFeedbackWidgetUrl(string serverUrl, CountlyFeedbackWid
StringBuilder sb = new StringBuilder();
sb.Append(serverUrl.TrimEnd('/'));
sb.Append("/feedback/").Append(widget.type).Append("?");
- sb.Append("widget_id=").Append(Uri.EscapeDataString(widget.widgetId));
+ // Uri.EscapeDataString throws on null; a null widgetId is a caller error, not a crash.
+ sb.Append("widget_id=").Append(Uri.EscapeDataString(widget.widgetId ?? ""));
Append(sb, "device_id", baseParams, "device_id");
Append(sb, "app_key", baseParams, "app_key");
Append(sb, "sdk_name", baseParams, "sdk_name");
Append(sb, "sdk_version", baseParams, "sdk_version");
Append(sb, "app_version", baseParams, "av");
sb.Append("&platform=windows");
- sb.Append("&custom=").Append(Uri.EscapeDataString("{\"tc\":1,\"rw\":1,\"xb\":1}"));
+ // Desktop = web-SDK model: no rw (fullscreen); xb = widget draws its own close button.
+ sb.Append("&custom=").Append(Uri.EscapeDataString("{\"tc\":1,\"xb\":1}"));
+ // The widget only accepts our post-load {type:'resize'} message if its `origin` matches
+ // the page's own origin (the widget page validates event.origin === ?origin= param).
+ // Without this the resize message is dropped and a fullscreen widget has no dimensions
+ // to fill to (renders as a fixed content-sized card). Sent unencoded, like the web SDK.
+ string origin = OriginOf(serverUrl);
+ if (origin != null) { sb.Append("&origin=").Append(origin); }
return sb.ToString();
}
+ /// Scheme+authority of the server URL (e.g. "https://host:port"), or null if unparseable.
+ private static string OriginOf(string serverUrl)
+ {
+ try {
+ return new Uri(serverUrl, UriKind.Absolute).GetLeftPart(UriPartial.Authority);
+ } catch {
+ return null;
+ }
+ }
+
private static void Append(StringBuilder sb, string outKey, IDictionary src, string srcKey)
{
if (src != null && src.TryGetValue(srcKey, out object v) && v != null) {
diff --git a/countlyCommon/countlyCommon/Modules/ModuleContent.cs b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
index 7ee93f4a..1429df7e 100644
--- a/countlyCommon/countlyCommon/Modules/ModuleContent.cs
+++ b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
@@ -4,6 +4,7 @@
using CountlySDK.CountlyCommon.Helpers;
using CountlySDK.CountlyCommon.Server.Responses;
using CountlySDK.Helpers;
+using Newtonsoft.Json.Linq;
using static CountlySDK.CountlyCommon.CountlyBase;
namespace CountlySDK.CountlyCommon
@@ -22,6 +23,7 @@ internal class ModuleContent : Content, IDisposable
private int _waitForDelay;
private volatile bool _fetching;
private int _generation; // bumped on ExitContentZone so an in-flight fetch can detect it was cancelled
+ private string[] _categories; // remembered so RefreshContentZone keeps the caller's category filter
private const int StartDelayMs = 4000;
public ModuleContent(RequestHelper requestHelper, string serverUrl)
@@ -40,6 +42,7 @@ public void EnterContentZone(string[] categories = null)
if (_inZone) { return; }
_shouldFetch = true;
_waitForDelay = 0;
+ _categories = categories; // remember for RefreshContentZone
int periodMs = Math.Max(1000, Countly.Instance.Configuration.ContentZoneTimerInterval * 1000);
if (_timer == null) {
_timer = new System.Threading.Timer(OnTick, categories, StartDelayMs, periodMs);
@@ -69,14 +72,21 @@ public void Dispose()
public void RefreshContentZone()
{
+ string[] categories = _categories; // capture before ExitContentZone so the filter survives the refresh
ExitContentZone();
- EnterContentZone(null);
+ EnterContentZone(categories);
}
public void PreviewContent(string contentId)
{
if (display == null || string.IsNullOrEmpty(contentId)) { return; }
- lock (_lock) { if (_inZone) { return; } }
+ lock (_lock) {
+ // Respect the shared fetch guard so a preview can't run concurrently with a timer
+ // fetch (which would stack two overlay windows on screen). FetchAndPresent's finally
+ // clears _fetching.
+ if (_inZone || _fetching) { return; }
+ _fetching = true;
+ }
FetchAndPresent(new string[0], contentId); // fire-and-forget; errors handled inside
}
@@ -128,6 +138,46 @@ private void OnContentClosed()
Action cb = Countly.Instance.Configuration != null ? Countly.Instance.Configuration.GlobalContentCallback : null;
if (cb != null) { cb(); }
}
+
+ // Records events the content emitted (action=event) and force-flushes the queue so the
+ // server can react immediately (journey processing). eventJson is a raw JSON array of
+ // { key, segmentation|sg } objects. Called by the content display on an action-event.
+ public void RecordContentEvents(string eventJson)
+ {
+ if (string.IsNullOrEmpty(eventJson)) { return; }
+ try {
+ JArray arr = JArray.Parse(eventJson);
+ bool recorded = false;
+ foreach (JToken t in arr) {
+ string key = (string)t["key"];
+ if (string.IsNullOrEmpty(key)) { continue; }
+ Segmentation seg = new Segmentation();
+ JToken sg = t["sg"] ?? t["segmentation"]; // "sg" takes precedence
+ if (sg is JObject sgObj) {
+ foreach (JProperty p in sgObj.Properties()) {
+ seg.Add(p.Name, TokenToInvariantString(p.Value));
+ }
+ }
+ _ = Countly.RecordEvent(key, 1, seg);
+ recorded = true;
+ }
+ if (recorded) { _ = Countly.Instance.Upload(); }
+ } catch (Exception ex) {
+ UtilityHelper.CountlyLogging("[ModuleContent] RecordContentEvents failed: " + ex.Message, LogLevel.ERROR);
+ }
+ }
+
+ // Stringifies a segmentation value locale-independently: JToken.ToString() formats numbers
+ // with the current culture (e.g. "4,5" on de-DE), which would corrupt the wire value.
+ private static string TokenToInvariantString(JToken t)
+ {
+ if (t == null || t.Type == JTokenType.Null) { return null; }
+ switch (t.Type) {
+ case JTokenType.Float: return ((double)t).ToString(System.Globalization.CultureInfo.InvariantCulture);
+ case JTokenType.Integer: return ((long)t).ToString(System.Globalization.CultureInfo.InvariantCulture);
+ default: return t.ToString(); // strings/bools are already invariant
+ }
+ }
}
internal class MockContent : Content
@@ -136,6 +186,7 @@ public void EnterContentZone(string[] categories = null) { }
public void ExitContentZone() { }
public void RefreshContentZone() { }
public void PreviewContent(string contentId) { }
+ public void RecordContentEvents(string eventJson) { }
}
/// Retrieves and displays Countly content (experimental).
@@ -145,6 +196,7 @@ public interface Content
void ExitContentZone();
void RefreshContentZone();
void PreviewContent(string contentId);
+ void RecordContentEvents(string eventJson);
}
///
diff --git a/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
index 534cea2f..9d5b0391 100644
--- a/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
+++ b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
@@ -95,7 +95,9 @@ public async Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JOb
segmentation.Add("closed", "1");
} else {
foreach (KeyValuePair kv in widgetResult) {
- segmentation.Add(kv.Key, Convert.ToString(kv.Value));
+ // InvariantCulture: a numeric answer (e.g. 4.5) must not pick up a locale decimal
+ // separator ("4,5") on the wire, which would corrupt server-side aggregation.
+ segmentation.Add(kv.Key, Convert.ToString(kv.Value, System.Globalization.CultureInfo.InvariantCulture));
}
}
@@ -105,7 +107,10 @@ public async Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JOb
public async Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget)
{
- if (widget == null) { return null; }
+ if (widget == null || string.IsNullOrEmpty(widget.widgetId)) {
+ UtilityHelper.CountlyLogging("[ModuleFeedback] ConstructFeedbackWidgetUrl, widget or widgetId is null/empty", LogLevel.ERROR);
+ return null;
+ }
IDictionary baseParams = await requestHelper.GetBaseParams();
return WidgetUrlBuilder.BuildFeedbackWidgetUrl(ServerUrl, widget, baseParams);
}
diff --git a/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs
index 009d0f19..347b8a4d 100644
--- a/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs
+++ b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs
@@ -11,19 +11,30 @@ namespace Countly.UI.WebView2.Tests
internal sealed class FakeHost : IWidgetWebHost
{
public event Action NavigationStarting;
+ public event Action ResizeRequested;
+ public event Action PageLoaded;
+ public event Action LoadFailed;
+ public WidgetSurface Surface { get; set; } = new WidgetSurface { X = 0, Y = 0, Width = 1920, Height = 1080 };
public string NavigatedUrl;
- public (int w, int h)? Resized;
+ public (int w, int h)? ReportedSize;
+ public WidgetRect Placed;
public bool Closed;
public void Navigate(string url) { NavigatedUrl = url; }
- public void ResizeTo(int w, int h) { Resized = (w, h); }
+ public void ReportSurfaceSize(int w, int h) { ReportedSize = (w, h); }
+ public void PlaceAndShow(WidgetRect r) { Placed = r; }
public void CloseHost() { Closed = true; }
- public void Fire(string url) { NavigationStarting?.Invoke(url); }
+
+ public void FireNav(string url) { NavigationStarting?.Invoke(url); }
+ public void FireResize(WidgetAction a) { ResizeRequested?.Invoke(a); }
+ public void FirePageLoaded() { PageLoaded?.Invoke(); }
+ public void FireLoadFailed() { LoadFailed?.Invoke(); }
}
internal sealed class FakeFeedback : Feedback
{
public bool ReportedClose;
+ public bool NullUrl; // simulate an unavailable widget (e.g. MockFeedback returns null)
public Task GetAvailableFeedbackWidgets() { return Task.FromResult(new CountlyFeedbackWidget[0]); }
public Task GetFeedbackWidgetData(CountlyFeedbackWidget w) { return Task.FromResult(null); }
public Task ReportFeedbackWidgetManually(CountlyFeedbackWidget w, JObject d, Dictionary r)
@@ -31,31 +42,56 @@ public Task ReportFeedbackWidgetManually(CountlyFeedbackWidget w, JObject d, Dic
if (r == null) { ReportedClose = true; }
return Task.FromResult(0);
}
- public Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget w) { return Task.FromResult("https://s/feedback/nps?widget_id=" + w.widgetId); }
+ public Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget w)
+ {
+ return Task.FromResult(NullUrl ? null : "https://s/feedback/nps?widget_id=" + w.widgetId);
+ }
}
public class FeedbackWidgetPresenterTests
{
[Fact]
- public async Task Start_NavigatesToConstructedUrl()
+ public async Task NavigatesOnStart_ReportsSurfaceOnPageLoad()
{
- FakeHost host = new FakeHost();
- FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), isLandscape: false, onClosed: () => { });
+ FakeHost host = new FakeHost { Surface = new WidgetSurface { Width = 1920, Height = 1080 } };
+ FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), () => { });
await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps });
Assert.Equal("https://s/feedback/nps?widget_id=w1", host.NavigatedUrl);
+ Assert.Null(host.ReportedSize); // not until the page loads
+
+ host.FirePageLoaded();
+ Assert.Equal((1920, 1080), host.ReportedSize);
+
+ host.FirePageLoaded(); // report once only
+ Assert.Equal((1920, 1080), host.ReportedSize);
}
[Fact]
- public async Task Resize_PicksPortraitRect_WhenPortrait()
+ public async Task ResizeRequested_PlacesCardAtResolvedRect()
{
- FakeHost host = new FakeHost();
- FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), isLandscape: false, onClosed: () => { });
+ FakeHost host = new FakeHost { Surface = new WidgetSurface { X = 0, Y = 0, Width = 1920, Height = 1080 } };
+ FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), () => { });
await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps });
- host.Fire("https://countly_action_event?cly_widget_command=1&action=resize_me&resize_me=" +
- Uri.EscapeDataString("{\"p\":{\"x\":0,\"y\":0,\"w\":320,\"h\":480},\"l\":{\"x\":0,\"y\":0,\"w\":800,\"h\":600}}"));
+ host.FireResize(new WidgetAction {
+ IsActionEvent = true, HasResize = true,
+ Landscape = new WidgetRect { X = 1420, Y = 460, W = 500, H = 620 },
+ Portrait = new WidgetRect { X = 0, Y = 0, W = 300, H = 400 }
+ });
+
+ Assert.Equal(1420, host.Placed.X);
+ Assert.Equal(500, host.Placed.W);
+ }
+
+ [Fact]
+ public async Task ResizeRequested_WithoutUsableRect_DoesNotPlace()
+ {
+ FakeHost host = new FakeHost();
+ FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), () => { });
+ await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps });
- Assert.Equal((320, 480), host.Resized);
+ host.FireResize(new WidgetAction { IsActionEvent = true, HasResize = false });
+ Assert.Null(host.Placed);
}
[Fact]
@@ -64,14 +100,42 @@ public async Task Close_ReportsClosedAndClosesHost()
FakeHost host = new FakeHost();
FakeFeedback fb = new FakeFeedback();
bool closedCb = false;
- FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, fb, isLandscape: false, onClosed: () => closedCb = true);
+ FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, fb, () => closedCb = true);
await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps });
- host.Fire("https://countly_action_event?cly_widget_command=1&close=1");
+ host.FireNav("https://countly_action_event/?cly_widget_command=1&close=1");
Assert.True(fb.ReportedClose);
Assert.True(host.Closed);
Assert.True(closedCb);
}
+
+ [Fact]
+ public async Task NullUrl_TearsDownWithoutNavigating()
+ {
+ FakeHost host = new FakeHost();
+ bool closedCb = false;
+ FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback { NullUrl = true }, () => closedCb = true);
+
+ await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps });
+
+ Assert.Null(host.NavigatedUrl); // never navigated to a null URL
+ Assert.True(host.Closed);
+ Assert.True(closedCb);
+ }
+
+ [Fact]
+ public async Task LoadFailed_ClosesHostAndFiresOnClosed()
+ {
+ FakeHost host = new FakeHost();
+ bool closedCb = false;
+ FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), () => closedCb = true);
+ await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps });
+
+ host.FireLoadFailed();
+
+ Assert.True(host.Closed);
+ Assert.True(closedCb);
+ }
}
}
diff --git a/ui/Countly.UI.WebView2.Tests/WidgetMessageParserTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetMessageParserTests.cs
new file mode 100644
index 00000000..b1928269
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/WidgetMessageParserTests.cs
@@ -0,0 +1,43 @@
+using CountlySDK.CountlyCommon;
+using CountlySDK.UI;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ public class WidgetMessageParserTests
+ {
+ [Fact]
+ public void ParsesResizeMe_BothOrientations()
+ {
+ string json = "{\"cly_widget_command\":1,\"action\":\"resize_me\",\"resize_me\":{\"p\":{\"x\":10,\"y\":20,\"w\":300,\"h\":400},\"l\":{\"x\":5,\"y\":6,\"w\":700,\"h\":350}}}";
+ Assert.True(WidgetMessageParser.TryParse(json, out WidgetAction a));
+ Assert.True(a.HasResize);
+ Assert.Equal(300, a.Portrait.W);
+ Assert.Equal(20, a.Portrait.Y);
+ Assert.Equal(700, a.Landscape.W);
+ }
+
+ [Fact]
+ public void ResizeMe_WithoutWidthHeight_IsNotAResize()
+ {
+ string json = "{\"cly_widget_command\":1,\"action\":\"resize_me\",\"resize_me\":{\"p\":{\"x\":0,\"y\":0},\"l\":{\"x\":0,\"y\":0}}}";
+ Assert.True(WidgetMessageParser.TryParse(json, out WidgetAction a));
+ Assert.False(a.HasResize);
+ }
+
+ [Fact]
+ public void ParsesClose()
+ {
+ string json = "{\"cly_widget_command\":1,\"close\":1}";
+ Assert.True(WidgetMessageParser.TryParse(json, out WidgetAction a));
+ Assert.True(a.Close);
+ }
+
+ [Fact]
+ public void NonWidgetJson_ReturnsFalse()
+ {
+ Assert.False(WidgetMessageParser.TryParse("{\"type\":\"resize\",\"width\":400}", out _));
+ Assert.False(WidgetMessageParser.TryParse("not json", out _));
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/WidgetPlacementTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetPlacementTests.cs
new file mode 100644
index 00000000..2e412620
--- /dev/null
+++ b/ui/Countly.UI.WebView2.Tests/WidgetPlacementTests.cs
@@ -0,0 +1,51 @@
+using CountlySDK.CountlyCommon;
+using CountlySDK.UI;
+using Xunit;
+
+namespace Countly.UI.WebView2.Tests
+{
+ public class WidgetPlacementTests
+ {
+ private static WidgetAction Resize(int px,int py,int pw,int ph,int lx,int ly,int lw,int lh) => new WidgetAction {
+ IsActionEvent = true, HasResize = true,
+ Portrait = new WidgetRect { X=px,Y=py,W=pw,H=ph },
+ Landscape = new WidgetRect { X=lx,Y=ly,W=lw,H=lh },
+ };
+
+ [Fact]
+ public void LandscapeSurface_PicksLandscape_AndOffsetsByOrigin()
+ {
+ var surface = new WidgetSurface { X = 100, Y = 200, Width = 1920, Height = 1080 };
+ var rect = WidgetPlacement.Resolve(Resize(0,0,300,400, 1420,460,500,620), surface);
+ Assert.NotNull(rect);
+ Assert.Equal(100 + 1420, rect.X);
+ Assert.Equal(200 + 460, rect.Y);
+ Assert.Equal(500, rect.W);
+ Assert.Equal(620, rect.H);
+ }
+
+ [Fact]
+ public void PortraitSurface_PicksPortrait()
+ {
+ var surface = new WidgetSurface { X = 0, Y = 0, Width = 800, Height = 1200 };
+ var rect = WidgetPlacement.Resolve(Resize(10,20,300,400, 0,0,700,350), surface);
+ Assert.Equal(300, rect.W);
+ Assert.Equal(10, rect.X);
+ }
+
+ [Fact]
+ public void ClampsWithinSurface()
+ {
+ var surface = new WidgetSurface { X = 0, Y = 0, Width = 500, Height = 500 };
+ var rect = WidgetPlacement.Resolve(Resize(400,400,300,300, 400,400,300,300), surface);
+ Assert.True(rect.X + rect.W <= 500);
+ Assert.True(rect.Y + rect.H <= 500);
+ }
+
+ [Fact]
+ public void NoUsableResize_ReturnsNull()
+ {
+ Assert.Null(WidgetPlacement.Resolve(new WidgetAction { IsActionEvent = true, HasResize = false }, new WidgetSurface { Width = 100, Height = 100 }));
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs
index 288b89b6..3db7cbde 100644
--- a/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs
+++ b/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs
@@ -7,16 +7,17 @@ namespace Countly.UI.WebView2.Tests
{
public class WidgetUrlBuilderTests
{
+ private static Dictionary Base() => new Dictionary {
+ { "app_key", "APPKEY" }, { "device_id", "DEV1" },
+ { "sdk_name", "csharp" }, { "sdk_version", "9.9.9" }, { "av", "1.2.3" }
+ };
+
[Fact]
public void BuildFeedbackWidgetUrl_ContainsRequiredParamsAndCustom()
{
- var baseParams = new Dictionary {
- { "app_key", "APPKEY" }, { "device_id", "DEV1" },
- { "sdk_name", "csharp" }, { "sdk_version", "9.9.9" }, { "av", "1.2.3" }
- };
var widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.survey };
- string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly", widget, baseParams);
+ string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly", widget, Base());
Assert.StartsWith("https://s.count.ly/feedback/survey?", url);
Assert.Contains("widget_id=w1", url);
@@ -25,8 +26,37 @@ public void BuildFeedbackWidgetUrl_ContainsRequiredParamsAndCustom()
Assert.Contains("platform=windows", url);
Assert.Contains("sdk_version=9.9.9", url);
Assert.Contains("app_version=1.2.3", url);
- Assert.Contains("%22rw%22%3A1", url); // "rw":1 (resizable opt-in), url-encoded
- Assert.Contains("%22tc%22%3A1", url);
+ Assert.Contains("%22tc%22%3A1", url); // "tc":1
+ Assert.Contains("%22xb%22%3A1", url); // "xb":1
+ }
+
+ [Fact]
+ public void Custom_DropsRw()
+ {
+ var widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.survey };
+ string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly/", widget, Base());
+ Assert.Contains("custom=" + System.Uri.EscapeDataString("{\"tc\":1,\"xb\":1}"), url);
+ Assert.DoesNotContain("%22rw%22", url); // no "rw" key
+ }
+
+ [Fact]
+ public void AppendsUnencodedOrigin_SchemeAndAuthorityOnly()
+ {
+ var widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps };
+ string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly/path/", widget, Base());
+ Assert.EndsWith("&origin=https://s.count.ly", url);
+ }
+
+ [Fact]
+ public void BuildFeedbackWidgetUrl_NullWidgetId_DoesNotThrow()
+ {
+ var widget = new CountlyFeedbackWidget { widgetId = null, type = FeedbackWidgetType.nps };
+
+ // Must not throw ArgumentNullException from Uri.EscapeDataString(null).
+ string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly", widget, Base());
+
+ Assert.StartsWith("https://s.count.ly/feedback/nps?", url);
+ Assert.Contains("widget_id=", url);
}
}
}
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Dpi.cs b/ui/Countly.UI.WebView2/CountlyWebView.Dpi.cs
new file mode 100644
index 00000000..7da8af7e
--- /dev/null
+++ b/ui/Countly.UI.WebView2/CountlyWebView.Dpi.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace CountlySDK.UI
+{
+ public static partial class CountlyWebView
+ {
+ ///
+ /// When true, feedback widgets anchor within the host app window; otherwise (default) the
+ /// whole screen. The host app must be per-monitor-DPI-aware for correct sizing/placement.
+ ///
+ public static bool ShowWidgetsWithinApp { get; set; }
+
+ private static readonly IntPtr PerMonitorAwareV2 = new IntPtr(-4);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetProcessDpiAwarenessContext(IntPtr value);
+
+ // Best-effort; only takes effect if no window exists yet. Hosts should also declare
+ // per-monitor DPI awareness (manifest or a module initializer) — see docs.
+ internal static void EnsurePerMonitorDpiAware()
+ {
+ try { SetProcessDpiAwarenessContext(PerMonitorAwareV2); } catch { }
+ }
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetThreadDpiAwarenessContext();
+
+ [DllImport("user32.dll")]
+ private static extern int GetAwarenessFromDpiAwarenessContext(IntPtr context);
+
+ [DllImport("shcore.dll")]
+ private static extern int GetScaleFactorForMonitor(IntPtr hMon, out int scale);
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr MonitorFromPoint(POINT pt, uint flags);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT { public int X, Y; }
+
+ private const uint MonitorDefaultToPrimary = 1;
+
+ ///
+ /// Synchronous best-effort estimate of window-units-per-CSS-px for the primary monitor,
+ /// computed WITHOUT a live webview so the first content fetch (which can precede the async
+ /// probe) already sizes correctly. If the process is DPI-aware, WPF already scales window
+ /// units to the monitor, so the factor is 1.0; if DPI-unaware, WPF uses 96-DPI units and we
+ /// multiply by the physical monitor scale. Returns 1.0 when the APIs are unavailable; the
+ /// content display then refines this from the page's devicePixelRatio.
+ ///
+ internal static double EstimateMonitorScale()
+ {
+ try {
+ int awareness = GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext());
+ if (awareness > 0) { return 1.0; } // 1 = system-aware, 2 = per-monitor-aware
+ IntPtr mon = MonitorFromPoint(new POINT { X = 0, Y = 0 }, MonitorDefaultToPrimary);
+ if (GetScaleFactorForMonitor(mon, out int pct) == 0 && pct > 0) { return pct / 100.0; }
+ } catch { /* fall through to 1.0 */ }
+ return 1.0;
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
index 13a61e9c..50394418 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
@@ -9,13 +9,14 @@ namespace CountlySDK.UI
public static partial class CountlyWebView
{
///
- /// Presents a feedback widget in a WebView2-hosted WinForms form owned by
- /// . Must be called on the UI thread. If the WebView2 runtime is
- /// missing, this is a graceful no-op (logs and invokes ).
+ /// Presents a feedback widget as a transparent card form placed where the widget asks
+ /// (corner of the screen, or the app window if is set).
+ /// Must be called on the UI thread. No-op (logs + onClosed) if the WebView2 runtime is missing.
///
public static void PresentFeedbackWidget(IWin32Window owner, CountlyFeedbackWidget widget, Action onClosed = null)
{
if (widget == null) { onClosed?.Invoke(); return; }
+ EnsurePerMonitorDpiAware();
if (!WebView2Runtime.IsAvailable(out _)) {
System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; cannot present widget");
@@ -23,30 +24,77 @@ public static void PresentFeedbackWidget(IWin32Window owner, CountlyFeedbackWidg
return;
}
+ WidgetSurface surface = ResolveSurfaceWinForms(owner);
+
Form form = new Form {
FormBorderStyle = FormBorderStyle.None,
- StartPosition = FormStartPosition.CenterParent,
- ClientSize = new Size(400, 600),
- ShowInTaskbar = false
+ StartPosition = FormStartPosition.Manual,
+ ShowInTaskbar = false,
+ TopMost = true,
+ // 1x1 on the real target monitor (not off-screen) so devicePixelRatio is measured
+ // correctly at init. Moved/sized by the presenter.
+ Location = new Point(surface.X, surface.Y),
+ ClientSize = new Size(1, 1)
};
+ // TransparencyKey lets the card's transparent margins show through.
+ form.BackColor = Color.Magenta;
+ form.TransparencyKey = Color.Magenta;
WebView2 webView = new WebView2 { Dock = DockStyle.Fill };
form.Controls.Add(webView);
- WinFormsWebView2WidgetHost adapter = new WinFormsWebView2WidgetHost(webView, form);
- FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape: false, onClosed);
+ WinFormsWebView2WidgetHost adapter = new WinFormsWebView2WidgetHost(webView, form, surface);
+ FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), onClosed);
- form.Load += async (_, e) => {
+ form.Load += async (_, __) => {
try {
await adapter.InitializeAsync();
await presenter.StartAsync(widget);
} catch (Exception ex) {
- // async void: a display failure must never crash the host app.
System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex);
- try { form.Close(); } catch { /* best-effort close during teardown; not actionable */ }
+ try { form.Close(); } catch { /* best-effort teardown */ }
onClosed?.Invoke();
}
};
form.Show(owner);
}
+
+ private static WidgetSurface ResolveSurfaceWinForms(IWin32Window owner)
+ {
+ Control ctl = owner as Control;
+ if (ShowWidgetsWithinApp && ctl != null) {
+ Form form = ctl.FindForm() ?? ctl as Form;
+ if (form != null) {
+ Point p = form.PointToScreen(Point.Empty);
+ // Control.DeviceDpi is net4.7+; on net462 (system-DPI only) read it from a device context.
+#if NET462
+ float s;
+ using (Graphics g = form.CreateGraphics()) { s = g.DpiX / 96f; }
+#else
+ float s = form.DeviceDpi / 96f;
+#endif
+ return new WidgetSurface {
+ X = (int)(p.X / s), Y = (int)(p.Y / s),
+ Width = (int)(form.ClientSize.Width / s), Height = (int)(form.ClientSize.Height / s)
+ };
+ }
+ }
+ // Screen.WorkingArea is physical px on a DPI-aware host; convert to DIPs (the WidgetSurface
+ // contract) like the within-app branch and the WPF equivalent. Uses the owner's DPI when
+ // available; falls back to 1.0 (no host to measure) which is correct on DPI-unaware hosts.
+ float scale = 1f;
+ if (ctl != null) {
+#if NET462
+ using (Graphics g = ctl.CreateGraphics()) { scale = g.DpiX / 96f; }
+#else
+ scale = ctl.DeviceDpi / 96f;
+#endif
+ }
+ if (scale <= 0) { scale = 1f; }
+ System.Drawing.Rectangle wa = Screen.PrimaryScreen.WorkingArea;
+ return new WidgetSurface {
+ X = (int)(wa.X / scale), Y = (int)(wa.Y / scale),
+ Width = (int)(wa.Width / scale), Height = (int)(wa.Height / scale)
+ };
+ }
}
}
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
index 31bbdf79..85af6b86 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
@@ -1,5 +1,6 @@
using System;
using System.Windows;
+using System.Windows.Media;
using CountlySDK.CountlyCommon;
using Microsoft.Web.WebView2.Wpf;
@@ -8,13 +9,15 @@ namespace CountlySDK.UI
public static partial class CountlyWebView
{
///
- /// Presents a feedback widget in a WebView2-hosted WPF window owned by
- /// . Must be called on the UI thread. If the WebView2 runtime is
- /// missing, this is a graceful no-op (logs and invokes ).
+ /// Presents a feedback widget as a transparent, click-through-surrounded card window placed
+ /// where the widget asks (corner of the screen, or the app window if
+ /// is set). Must be called on the UI thread. No-op (logs +
+ /// onClosed) if the WebView2 runtime is missing.
///
public static void PresentFeedbackWidget(Window owner, CountlyFeedbackWidget widget, Action onClosed = null)
{
if (widget == null) { onClosed?.Invoke(); return; }
+ EnsurePerMonitorDpiAware();
if (!WebView2Runtime.IsAvailable(out _)) {
System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; cannot present widget");
@@ -22,34 +25,63 @@ public static void PresentFeedbackWidget(Window owner, CountlyFeedbackWidget wid
return;
}
+ WidgetSurface surface = ResolveSurface(owner);
+
+ // Transparent card window; starts tiny + off-screen so WebView2 can initialize, then the
+ // presenter moves/sizes it to the widget's requested rect.
+ // Opaque borderless window: a transparent (AllowsTransparency) window hit-tests by WPF's
+ // own visual alpha, which treats the WebView2 region as transparent -> clicks fall
+ // through the card. Opaque keeps the card interactive; clicks outside simply aren't ours.
Window host = new Window {
- Owner = owner,
- WindowStartupLocation = WindowStartupLocation.CenterOwner,
- Width = 400,
- Height = 600,
- ResizeMode = ResizeMode.NoResize,
WindowStyle = WindowStyle.None,
- ShowInTaskbar = false
+ ResizeMode = ResizeMode.NoResize,
+ ShowInTaskbar = false,
+ Topmost = true,
+ // Owner couples the card to the host app's lifecycle/z-order (minimizes and closes
+ // with it, never orphaned on top of other apps), matching the WinForms Show(owner) path.
+ Owner = owner,
+ WindowStartupLocation = WindowStartupLocation.Manual,
+ // Start as a 1x1 window on the real target monitor (not off-screen) so the
+ // devicePixelRatio measured at init reflects the correct monitor. Moved by PlaceAndShow.
+ Left = surface.X, Top = surface.Y, Width = 1, Height = 1
};
WebView2 webView = new WebView2();
host.Content = webView;
- WebView2WidgetHost adapter = new WebView2WidgetHost(webView, host);
- bool isLandscape = owner != null && owner.ActualWidth >= owner.ActualHeight;
- FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape, onClosed);
+ WebView2WidgetHost adapter = new WebView2WidgetHost(webView, host, surface);
+ FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), onClosed);
- host.Loaded += async (_, e) => {
+ host.Loaded += async (_, __) => {
try {
await adapter.InitializeAsync();
await presenter.StartAsync(widget);
} catch (Exception ex) {
- // async void: a display failure must never crash the host app.
System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex);
- try { host.Close(); } catch { /* best-effort close during teardown; not actionable */ }
+ try { host.Close(); } catch { /* best-effort teardown */ }
onClosed?.Invoke();
}
};
host.Show();
}
+
+ private static WidgetSurface ResolveSurface(Window owner)
+ {
+ if (ShowWidgetsWithinApp && owner != null) {
+ Point tl = owner.PointToScreen(new Point(0, 0));
+ DpiScale dpi = VisualTreeHelper.GetDpi(owner);
+ return new WidgetSurface {
+ X = (int)(tl.X / dpi.DpiScaleX),
+ Y = (int)(tl.Y / dpi.DpiScaleY),
+ Width = (int)owner.ActualWidth,
+ Height = (int)owner.ActualHeight
+ };
+ }
+ return new WidgetSurface {
+ X = (int)SystemParameters.WorkArea.Left,
+ Y = (int)SystemParameters.WorkArea.Top,
+ Width = (int)SystemParameters.WorkArea.Width,
+ Height = (int)SystemParameters.WorkArea.Height
+ };
+ }
}
}
diff --git a/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs b/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
index 67b6d660..6242c6eb 100644
--- a/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
+++ b/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
@@ -5,26 +5,35 @@
namespace CountlySDK.UI
{
///
- /// Drives a feedback widget in an : builds the display URL,
- /// interprets the widget's countly_action_event navigations (resize / close), reports
- /// the result, and notifies on close. Contains ALL the display logic and no WebView2 types,
- /// so it is fully unit-testable with a fake host.
+ /// Drives a feedback widget: navigates the URL, reports the surface size once the page has
+ /// loaded (so the widget can compute its corner rect), places the card window at the widget's
+ /// requested rect, and handles close. No WebView2 types — unit-testable with a fake host.
///
public class FeedbackWidgetPresenter
{
private readonly IWidgetWebHost _host;
private readonly Feedback _feedback;
- private readonly bool _isLandscape;
private readonly Action _onClosed;
private CountlyFeedbackWidget _widget;
+ private bool _surfaceReported;
- public FeedbackWidgetPresenter(IWidgetWebHost host, Feedback feedback, bool isLandscape, Action onClosed)
+ public FeedbackWidgetPresenter(IWidgetWebHost host, Feedback feedback, Action onClosed)
{
_host = host;
_feedback = feedback;
- _isLandscape = isLandscape;
_onClosed = onClosed;
_host.NavigationStarting += OnNavigationStarting;
+ _host.ResizeRequested += OnResizeRequested;
+ _host.PageLoaded += OnPageLoaded;
+ _host.LoadFailed += OnLoadFailed;
+ }
+
+ // The widget URL failed to load; dismiss the (invisible) card and notify the caller so the
+ // presentation doesn't wedge with a stuck window and no onClosed callback.
+ private void OnLoadFailed()
+ {
+ _host.CloseHost();
+ _onClosed?.Invoke();
}
/// Fetches the widget's display URL and navigates the host to it.
@@ -32,21 +41,39 @@ public async Task StartAsync(CountlyFeedbackWidget widget)
{
_widget = widget;
string url = await _feedback.ConstructFeedbackWidgetUrl(widget);
+ // A null/empty URL means the widget is unavailable (e.g. Feedback consent not given, so
+ // the core returns MockFeedback). Tear down cleanly rather than navigating to null,
+ // which would throw and leave a 1x1 window flashing on screen.
+ if (string.IsNullOrEmpty(url)) {
+ _host.CloseHost();
+ _onClosed?.Invoke();
+ return;
+ }
_host.Navigate(url);
}
- private void OnNavigationStarting(string url)
+ // The widget only computes a correct rect once it knows the viewport, and it only accepts
+ // our resize message after its own listener is attached — i.e. after the page has loaded.
+ private void OnPageLoaded()
{
- WidgetAction action = WidgetActionParser.Parse(url);
- if (!action.IsActionEvent) { return; }
+ if (_surfaceReported) { return; }
+ _surfaceReported = true;
+ _host.ReportSurfaceSize(_host.Surface.Width, _host.Surface.Height);
+ // Placement happens when the widget posts resize_me (OnResizeRequested). Widgets that
+ // never post one (e.g. the rating template) are handled by the host's own fallback.
+ }
- if (action.HasResize) {
- WidgetRect r = _isLandscape ? (action.Landscape ?? action.Portrait) : (action.Portrait ?? action.Landscape);
- if (r != null) { _host.ResizeTo(r.W, r.H); }
- }
+ private void OnResizeRequested(WidgetAction action)
+ {
+ WidgetRect rect = WidgetPlacement.Resolve(action, _host.Surface);
+ if (rect == null) { return; }
+ _host.PlaceAndShow(rect);
+ }
- if (action.Close) {
- // Report the widget as closed/cancelled (null result), then dismiss.
+ private void OnNavigationStarting(string url)
+ {
+ WidgetAction action = WidgetActionParser.Parse(url);
+ if (action.IsActionEvent && action.Close) {
_ = _feedback.ReportFeedbackWidgetManually(_widget, null, null);
_host.CloseHost();
_onClosed?.Invoke();
diff --git a/ui/Countly.UI.WebView2/IWidgetWebHost.cs b/ui/Countly.UI.WebView2/IWidgetWebHost.cs
index 4c8e241b..c1fb038e 100644
--- a/ui/Countly.UI.WebView2/IWidgetWebHost.cs
+++ b/ui/Countly.UI.WebView2/IWidgetWebHost.cs
@@ -1,23 +1,40 @@
using System;
+using CountlySDK.CountlyCommon;
namespace CountlySDK.UI
{
///
- /// Abstraction over the embedded browser that hosts a widget. Keeps all presentation logic
- /// in testable without instantiating a real WebView2
- /// (which requires a UI thread + the WebView2 runtime). The concrete adapters
- /// (WebView2WidgetHost / WinFormsWebView2WidgetHost) are thin implementations.
+ /// Abstraction over the embedded browser that hosts a widget. Keeps the presentation logic in
+ /// testable without a real WebView2. The presenter reports
+ /// the surface size to the widget, then places the card window at the rect the widget requests
+ /// (its resize_me). The concrete adapters (WPF / WinForms) are thin.
///
public interface IWidgetWebHost
{
- /// Raised for each navigation the widget initiates; the argument is the target URI.
+ /// Raised for each navigation the widget initiates; the argument is the target URI (used for close).
event Action NavigationStarting;
+ /// Raised when the widget posts a usable resize_me (bridged from the page).
+ event Action ResizeRequested;
+
+ /// Raised once the widget page has finished loading (safe to post the surface size).
+ event Action PageLoaded;
+
+ /// Raised when the initial widget navigation fails (offline/404/error) before any
+ /// successful load, so the presenter can dismiss the otherwise-invisible card and fire onClosed.
+ event Action LoadFailed;
+
+ /// The coordinate space handed to the widget (DIPs, screen-absolute origin).
+ WidgetSurface Surface { get; }
+
/// Loads the given URL in the host.
void Navigate(string url);
- /// Resizes the owning window to the widget's requested content size (CSS px).
- void ResizeTo(int cssWidth, int cssHeight);
+ /// Tells the widget how much room it has by posting a {type:'resize',width,height} message.
+ void ReportSurfaceSize(int width, int height);
+
+ /// Positions + sizes the card window to the widget's requested rect (DIPs) and shows it.
+ void PlaceAndShow(WidgetRect screenRect);
/// Closes/dismisses the host window.
void CloseHost();
diff --git a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
index 9d4baade..7c38ca7d 100644
--- a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
+++ b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Windows;
using System.Windows.Threading;
using CountlySDK.CountlyCommon;
@@ -8,33 +9,105 @@ namespace CountlySDK.UI
{
///
/// WPF implementation of the core bridge: reports the primary
- /// screen size and shows server-placed content in a borderless, top-most, non-modal WebView2
- /// window at the given coordinates. Constructed on the UI thread (captures its Dispatcher).
+ /// work-area size and shows server-placed content in a borderless, top-most, non-modal WebView2
+ /// window at the server-computed coordinates. Constructed on the UI thread (captures its
+ /// Dispatcher).
+ ///
+ /// Coordinate model: the server computes geo in the units of the resolution we report,
+ /// and the content page itself lays out in CSS px. So we report the work area in CSS px
+ /// (physical ÷ monitor-scale) and place the window at geo × monitor-scale for BOTH
+ /// position and size. On a DPI-aware host or a 100% monitor the scale is 1.0 and this is a
+ /// no-op; on a DPI-unaware host at 125% it makes a fullscreen item fill exactly and a top-right
+ /// item land on the screen edge (instead of scaling size only, which pushed content off-screen).
///
internal sealed class WebView2ContentDisplay : IContentDisplay
{
private readonly Dispatcher _dispatcher;
- private readonly int _screenW, _screenH;
+
+ // window-units per CSS-px (= devicePixelRatio / host DPI scale). Static so it survives across
+ // content shows: GetScreen() runs during the fetch (before any content webview exists) and
+ // needs it, but it can only be *measured* from a live page. A one-time hidden-webview probe
+ // (started at construction, well before the first fetch) seeds it with the same signal the
+ // widget adapter uses; each content show then refines it from its own devicePixelRatio.
+ // Seeded synchronously (Win32) so the first fetch is correct even if it precedes the probe.
+ private static double _monitorScale = CountlyWebView.EstimateMonitorScale();
+ private static bool _scaleProbeStarted;
public WebView2ContentDisplay()
{
_dispatcher = Dispatcher.CurrentDispatcher; // captured on the UI thread
- _screenW = (int)SystemParameters.WorkArea.Width;
- _screenH = (int)SystemParameters.WorkArea.Height;
+ StartScaleProbe();
+ }
+
+ // Measure window-units-per-CSS-px once, before any content is fetched, from a throwaway 1×1
+ // WebView2 on the primary work area — exactly how the widget adapter measures it (a Win32 DPI
+ // estimate is unreliable because it cannot see WPF's own DPI notion). On a DPI-aware host or a
+ // 100% monitor this is 1.0; on a DPI-unaware host at 125% it is 1.25.
+ private void StartScaleProbe()
+ {
+ if (_scaleProbeStarted) { return; }
+ _scaleProbeStarted = true;
+ _dispatcher.BeginInvoke(new Action(async () => {
+ Window probe = null;
+ try {
+ WebView2 wv = new WebView2();
+ probe = new Window {
+ WindowStyle = WindowStyle.None,
+ ResizeMode = ResizeMode.NoResize,
+ ShowInTaskbar = false,
+ ShowActivated = false,
+ WindowStartupLocation = WindowStartupLocation.Manual,
+ Left = SystemParameters.WorkArea.Left, Top = SystemParameters.WorkArea.Top,
+ Width = 1, Height = 1,
+ Content = wv
+ };
+ probe.Show(); // realize the HWND on the primary monitor so devicePixelRatio is accurate
+ await wv.EnsureCoreWebView2Async(null);
+ double wpfDpi = System.Windows.Media.VisualTreeHelper.GetDpi(probe).DpiScaleX;
+ string dprJson = await wv.CoreWebView2.ExecuteScriptAsync("window.devicePixelRatio");
+ double.TryParse(dprJson, NumberStyles.Any, CultureInfo.InvariantCulture, out double dpr);
+ if (dpr > 0 && wpfDpi > 0) { _monitorScale = dpr / wpfDpi; }
+ } catch (Exception ex) {
+ // Non-fatal: fall back to 1.0 now and let the first content show refine the scale.
+ System.Diagnostics.Debug.WriteLine("[CountlyWebView] content scale probe failed: " + ex);
+ } finally {
+ try { if (probe != null) { probe.Close(); } } catch { }
+ }
+ }));
}
public ContentScreen GetScreen()
{
- return new ContentScreen { Width = _screenW, Height = _screenH };
+ // Report the work area in CSS px so the server's geo comes back in CSS px — the unit the
+ // content page lays out in.
+ double scale = _monitorScale > 0 ? _monitorScale : 1.0;
+ return new ContentScreen {
+ Width = (int)(SystemParameters.WorkArea.Width / scale),
+ Height = (int)(SystemParameters.WorkArea.Height / scale)
+ };
}
public void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed)
{
_dispatcher.BeginInvoke(new Action(() => {
try {
- ContentPlacement r = (_screenW >= _screenH ? landscape : portrait) ?? portrait ?? landscape;
+ // Capture the scale used to report the resolution that produced this geo; place
+ // with the SAME scale (do not re-apply a freshly measured one to this window, or
+ // geo computed for the old resolution would be scaled twice).
+ double scale = _monitorScale > 0 ? _monitorScale : 1.0;
+ double originX = SystemParameters.WorkArea.Left;
+ double originY = SystemParameters.WorkArea.Top;
+ bool landscapeScreen = SystemParameters.WorkArea.Width >= SystemParameters.WorkArea.Height;
+
+ ContentPlacement r = (landscapeScreen ? landscape : portrait) ?? portrait ?? landscape;
if (r == null) { onClosed?.Invoke(); return; }
+ // Capture the content module NOW (consent is good — we were just asked to present)
+ // and relay events through it, rather than re-resolving Countly.Instance.Content()
+ // per navigation, which could return a no-op MockContent if consent is revoked or
+ // the SDK is re-initialised while this overlay is still on screen.
+ Content contentModule = CountlySDK.Countly.Instance.Content();
+
Window host = new Window {
WindowStyle = WindowStyle.None,
ResizeMode = ResizeMode.NoResize,
@@ -42,27 +115,54 @@ public void Present(ContentPlacement portrait, ContentPlacement landscape, strin
ShowInTaskbar = false,
ShowActivated = false,
WindowStartupLocation = WindowStartupLocation.Manual,
- Left = r.X, Top = r.Y, Width = r.W, Height = r.H
+ Left = originX + r.X * scale, Top = originY + r.Y * scale,
+ Width = r.W * scale, Height = r.H * scale
};
WebView2 webView = new WebView2();
host.Content = webView;
+ // WPF doesn't dispose child controls on close, so dispose the WebView2 explicitly
+ // to avoid leaking its browser host (msedgewebview2.exe) per content shown.
+ host.Closed += (_, __) => { try { webView.Dispose(); } catch { /* teardown best-effort */ } };
host.Loaded += async (_, e) => {
try {
await webView.EnsureCoreWebView2Async(null);
+
+ // Refine the cached monitor scale from the live page for the NEXT fetch.
+ // Not re-applied to this window (see the capture note above).
+ try {
+ double wpfDpi = System.Windows.Media.VisualTreeHelper.GetDpi(host).DpiScaleX;
+ string dprJson = await webView.CoreWebView2.ExecuteScriptAsync("window.devicePixelRatio");
+ double.TryParse(dprJson, NumberStyles.Any, CultureInfo.InvariantCulture, out double dpr);
+ if (dpr > 0 && wpfDpi > 0) { _monitorScale = dpr / wpfDpi; }
+ } catch { }
+
webView.CoreWebView2.NavigationStarting += (_, e2) => {
WidgetAction a = WidgetActionParser.Parse(e2.Uri);
if (!a.IsActionEvent) { return; }
+ // Action-event URLs are signals, never real navigations. Cancel ALL of
+ // them, or the WebView navigates to the invalid countly_action_event host
+ // and the content is replaced by a chrome-error page.
+ e2.Cancel = true;
+ // Process event/link first, close last (per the content protocol).
+ if (a.EventPayload != null) {
+ try { contentModule.RecordContentEvents(a.EventPayload); }
+ catch (Exception ev) { System.Diagnostics.Debug.WriteLine("[CountlyWebView] content event relay failed: " + ev); }
+ }
+ if (a.Link != null) {
+ // ShellExecute routes http(s) -> default browser and custom schemes -> deeplink handler.
+ try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(a.Link) { UseShellExecute = true }); }
+ catch (Exception lx) { System.Diagnostics.Debug.WriteLine("[CountlyWebView] content link open failed: " + lx); }
+ }
if (a.HasResize) {
- WidgetRect rect = _screenW >= _screenH ? (a.Landscape ?? a.Portrait) : (a.Portrait ?? a.Landscape);
+ WidgetRect rect = landscapeScreen ? (a.Landscape ?? a.Portrait) : (a.Portrait ?? a.Landscape);
if (rect != null) {
- host.Left = rect.X; host.Top = rect.Y;
- host.Width = rect.W; host.Height = rect.H;
+ host.Left = originX + rect.X * scale; host.Top = originY + rect.Y * scale;
+ host.Width = rect.W * scale; host.Height = rect.H * scale;
}
}
if (a.Close) {
- e2.Cancel = true;
host.Close();
onClosed?.Invoke();
}
diff --git a/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs b/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs
index 48d17c00..2b033f97 100644
--- a/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs
+++ b/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs
@@ -1,41 +1,128 @@
using System;
-using System.Drawing;
using System.Threading.Tasks;
using System.Windows.Forms;
+using CountlySDK.CountlyCommon;
using Microsoft.Web.WebView2.WinForms;
namespace CountlySDK.UI
{
///
- /// Thin WinForms adapter mapping onto a WebView2 control hosted
- /// in a caller-owned . Logic-free by design.
+ /// Thin WinForms adapter mapping onto a WebView2 in a caller-owned
+ /// card . Mirrors the WPF adapter (scaled placement + measure-content fallback).
///
internal sealed class WinFormsWebView2WidgetHost : IWidgetWebHost
{
public event Action NavigationStarting;
+ public event Action ResizeRequested;
+ public event Action PageLoaded;
+ public event Action LoadFailed;
+ public WidgetSurface Surface { get; }
private readonly WebView2 _webView;
private readonly Form _form;
+ private double _scale = 1.0;
+ private bool _placed;
+ private bool _pageLoaded;
- public WinFormsWebView2WidgetHost(WebView2 webView, Form form)
+ public WinFormsWebView2WidgetHost(WebView2 webView, Form form, WidgetSurface surface)
{
_webView = webView;
_form = form;
+ Surface = surface;
}
public async Task InitializeAsync()
{
await _webView.EnsureCoreWebView2Async(null);
+ _webView.DefaultBackgroundColor = System.Drawing.Color.White;
+
+ try {
+ // Control.DeviceDpi is net4.7+; on net462 (system-DPI only) read it from a device context.
+#if NET462
+ double hostScale;
+ using (System.Drawing.Graphics g = _form.CreateGraphics()) { hostScale = g.DpiX / 96f; }
+#else
+ double hostScale = _form.DeviceDpi / 96f;
+#endif
+ string dprJson = await _webView.CoreWebView2.ExecuteScriptAsync("window.devicePixelRatio");
+ double.TryParse(dprJson, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out double dpr);
+ if (dpr > 0 && hostScale > 0) { _scale = dpr / hostScale; }
+ } catch { /* keep _scale = 1.0 */ }
+
+ await _webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(
+ "(function(){var w=window.chrome&&window.chrome.webview;if(!w)return;" +
+ "window.addEventListener('message',function(ev){try{var d=typeof ev.data==='string'?JSON.parse(ev.data):ev.data;" +
+ "if(d&&d.cly_widget_command){w.postMessage(JSON.stringify(d));}}catch(e){}});})();");
+
+ _webView.CoreWebView2.WebMessageReceived += (s, e) => {
+ string json;
+ try { json = e.TryGetWebMessageAsString(); } catch { return; }
+ if (WidgetMessageParser.TryParse(json, out WidgetAction a) && a.HasResize) {
+ ResizeRequested?.Invoke(a);
+ }
+ };
+
_webView.CoreWebView2.NavigationStarting += (s, e) => NavigationStarting?.Invoke(e.Uri);
+ _webView.CoreWebView2.NavigationCompleted += async (s, e) => {
+ if (!e.IsSuccess) {
+ // Initial widget navigation failed (offline/404/error) before any successful load:
+ // dismiss the invisible 1x1 card. Later failures (post-load) are ignored here.
+ if (!_pageLoaded) { LoadFailed?.Invoke(); }
+ return;
+ }
+ _pageLoaded = true;
+ PageLoaded?.Invoke();
+ await Task.Delay(900);
+ if (!_placed) { PlaceByMeasuredContent(); }
+ };
}
public void Navigate(string url) { _webView.CoreWebView2.Navigate(url); }
- public void ResizeTo(int cssWidth, int cssHeight)
+ public void ReportSurfaceSize(int width, int height)
+ {
+ _ = _webView.CoreWebView2.ExecuteScriptAsync("window.postMessage({type:'resize',width:" + width + ",height:" + height + "},'*');");
+ }
+
+ public void PlaceAndShow(WidgetRect r)
+ {
+ _placed = true;
+ int w = (int)(r.W * _scale);
+ int h = (int)(r.H * _scale);
+ int localX = r.X - Surface.X;
+ int localY = r.Y - Surface.Y;
+ int x = Surface.X + Math.Max(0, Math.Min(localX, Math.Max(0, Surface.Width - w)));
+ int y = Surface.Y + Math.Max(0, Math.Min(localY, Math.Max(0, Surface.Height - h)));
+ _form.Location = new System.Drawing.Point(x, y);
+ _form.ClientSize = new System.Drawing.Size(w, h);
+ if (!_form.Visible) { _form.Show(); }
+ }
+
+ private async void PlaceByMeasuredContent()
{
- // WinForms is pixel-based; convert CSS px to device px for the form's current DPI.
- float scale = _form.DeviceDpi / 96f;
- _form.ClientSize = new Size((int)(cssWidth * scale), (int)(cssHeight * scale));
+ _placed = true;
+ try {
+ const int cssW = 400;
+ int w = (int)(cssW * _scale);
+ _form.Location = new System.Drawing.Point(Surface.X, Surface.Y);
+ _form.ClientSize = new System.Drawing.Size(w, Surface.Height);
+ if (!_form.Visible) { _form.Show(); }
+ await Task.Delay(400);
+
+ string js = "(function(){var e=document.getElementById('widget-body');" +
+ "return Math.ceil(e?e.scrollHeight:document.body.scrollHeight);})()";
+ int.TryParse(await _webView.CoreWebView2.ExecuteScriptAsync(js),
+ System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out int cssH);
+ if (cssH <= 0) { cssH = 500; }
+ int h = (int)(cssH * _scale);
+ if (h > Surface.Height) { h = Surface.Height; }
+
+ int x = Surface.X + Math.Max(0, (Surface.Width - w) / 2);
+ int y = Surface.Y + Math.Max(0, (Surface.Height - h) / 2);
+ _form.Location = new System.Drawing.Point(x, y);
+ _form.ClientSize = new System.Drawing.Size(w, h);
+ } catch { /* best-effort fallback */ }
}
public void CloseHost() { _form.Close(); }
diff --git a/ui/Countly.UI.WebView2/WebView2WidgetHost.cs b/ui/Countly.UI.WebView2/WebView2WidgetHost.cs
index c55ae9d8..4bba0665 100644
--- a/ui/Countly.UI.WebView2/WebView2WidgetHost.cs
+++ b/ui/Countly.UI.WebView2/WebView2WidgetHost.cs
@@ -1,41 +1,148 @@
using System;
using System.Threading.Tasks;
using System.Windows;
+using CountlySDK.CountlyCommon;
using Microsoft.Web.WebView2.Wpf;
namespace CountlySDK.UI
{
///
- /// Thin WPF adapter mapping onto a WebView2 control hosted in a
- /// caller-owned . Intentionally logic-free — all behavior lives in
- /// .
+ /// Thin WPF adapter mapping onto a WebView2 in an opaque, borderless
+ /// caller-owned card . Sizes the window to the widget's resize_me rect
+ /// (scaled by the measured window-units-per-CSS-px); if no resize_me arrives (legacy rating
+ /// template), it falls back to measuring the rendered content.
///
internal sealed class WebView2WidgetHost : IWidgetWebHost
{
public event Action NavigationStarting;
+ public event Action ResizeRequested;
+ public event Action PageLoaded;
+ public event Action LoadFailed;
+ public WidgetSurface Surface { get; }
private readonly WebView2 _webView;
private readonly Window _window;
+ // window-units per CSS-px (= devicePixelRatio / host DPI scale). 1.0 until measured at init.
+ private double _scale = 1.0;
+ private bool _placed;
+ private bool _pageLoaded;
- public WebView2WidgetHost(WebView2 webView, Window window)
+ public WebView2WidgetHost(WebView2 webView, Window window, WidgetSurface surface)
{
_webView = webView;
_window = window;
+ Surface = surface;
+ // WPF (unlike WinForms) does not dispose child controls when a Window closes, so the
+ // WebView2's CoreWebView2 browser host would leak (an orphaned msedgewebview2.exe).
+ // Dispose it on close, covering every teardown path (CloseHost, load failure, user close).
+ _window.Closed += (_, __) => { try { _webView.Dispose(); } catch { /* teardown best-effort */ } };
}
public async Task InitializeAsync()
{
await _webView.EnsureCoreWebView2Async(null);
+ _webView.DefaultBackgroundColor = System.Drawing.Color.White;
+
+ // Measure window-units-per-CSS-px = devicePixelRatio / host DPI scale. On a DPI-unaware
+ // host this is the monitor scale (e.g. 1.25); on a DPI-aware host it is 1.0. Read on the
+ // blank page (already on the target monitor) so it's ready before the first resize_me.
+ try {
+ double wpfDpi = System.Windows.Media.VisualTreeHelper.GetDpi(_window).DpiScaleX;
+ string dprJson = await _webView.CoreWebView2.ExecuteScriptAsync("window.devicePixelRatio");
+ double.TryParse(dprJson, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out double dpr);
+ if (dpr > 0 && wpfDpi > 0) { _scale = dpr / wpfDpi; }
+ } catch { /* keep _scale = 1.0 */ }
+
+ // Bridge: the widget posts resize_me/close to window.parent; forward those to the host.
+ await _webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(
+ "(function(){var w=window.chrome&&window.chrome.webview;if(!w)return;" +
+ "window.addEventListener('message',function(ev){try{var d=typeof ev.data==='string'?JSON.parse(ev.data):ev.data;" +
+ "if(d&&d.cly_widget_command){w.postMessage(JSON.stringify(d));}}catch(e){}});})();");
+
+ _webView.CoreWebView2.WebMessageReceived += (s, e) => {
+ string json;
+ try { json = e.TryGetWebMessageAsString(); } catch { return; }
+ if (WidgetMessageParser.TryParse(json, out WidgetAction a) && a.HasResize) {
+ ResizeRequested?.Invoke(a);
+ }
+ };
+
_webView.CoreWebView2.NavigationStarting += (s, e) => NavigationStarting?.Invoke(e.Uri);
+ _webView.CoreWebView2.NavigationCompleted += async (s, e) => {
+ if (!e.IsSuccess) {
+ // The very first navigation is the widget URL. If it fails before any successful
+ // load (offline/404/error page), the card would stay an invisible, undismissed
+ // 1x1 window — dismiss it. Later failures (e.g. the cancelled countly_action_event
+ // close navigation) happen after _pageLoaded and are ignored here.
+ if (!_pageLoaded) { LoadFailed?.Invoke(); }
+ return;
+ }
+ _pageLoaded = true;
+ PageLoaded?.Invoke();
+ // Fallback: if no resize_me placed the widget shortly after load (e.g. the rating
+ // template, which never sends one), measure the rendered content and place it.
+ await Task.Delay(900);
+ if (!_placed) { PlaceByMeasuredContent(); }
+ };
}
public void Navigate(string url) { _webView.CoreWebView2.Navigate(url); }
- public void ResizeTo(int cssWidth, int cssHeight)
+ public void ReportSurfaceSize(int width, int height)
+ {
+ string script = "window.postMessage({type:'resize',width:" + width + ",height:" + height + "},'*');";
+ _ = _webView.CoreWebView2.ExecuteScriptAsync(script);
+ }
+
+ public void PlaceAndShow(WidgetRect r)
+ {
+ _placed = true;
+ // Scale the window by the measured window-units-per-CSS-px so the webview's CSS viewport
+ // equals the card's CSS size -> content fits. Re-clamp to stay on-screen.
+ int w = (int)(r.W * _scale);
+ int h = (int)(r.H * _scale);
+ int localX = r.X - Surface.X;
+ int localY = r.Y - Surface.Y;
+ int x = Surface.X + Math.Max(0, Math.Min(localX, Math.Max(0, Surface.Width - w)));
+ int y = Surface.Y + Math.Max(0, Math.Min(localY, Math.Max(0, Surface.Height - h)));
+ _window.Left = x;
+ _window.Top = y;
+ _window.Width = w;
+ _window.Height = h;
+ if (!_window.IsVisible) { _window.Show(); }
+ }
+
+ // Fallback for widgets that send no resize_me: lay the content out at a default width,
+ // measure its height, then center-place the window sized to it.
+ private async void PlaceByMeasuredContent()
{
- // WPF sizes are device-independent units (96=1.0), which match WebView2's CSS px.
- _window.Width = cssWidth;
- _window.Height = cssHeight;
+ _placed = true;
+ try {
+ const int cssW = 400;
+ int w = (int)(cssW * _scale);
+ _window.Left = Surface.X;
+ _window.Top = Surface.Y;
+ _window.Width = w;
+ _window.Height = Surface.Height;
+ if (!_window.IsVisible) { _window.Show(); }
+ await Task.Delay(400);
+
+ string js = "(function(){var e=document.getElementById('widget-body');" +
+ "return Math.ceil(e?e.scrollHeight:document.body.scrollHeight);})()";
+ int.TryParse(await _webView.CoreWebView2.ExecuteScriptAsync(js),
+ System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out int cssH);
+ if (cssH <= 0) { cssH = 500; }
+ int h = (int)(cssH * _scale);
+ if (h > Surface.Height) { h = Surface.Height; }
+
+ int x = Surface.X + Math.Max(0, (Surface.Width - w) / 2);
+ int y = Surface.Y + Math.Max(0, (Surface.Height - h) / 2);
+ _window.Left = x;
+ _window.Top = y;
+ _window.Width = w;
+ _window.Height = h;
+ } catch (Exception ex) { System.Diagnostics.Debug.WriteLine("[CountlyWebView] widget content-measure fallback failed: " + ex); }
}
public void CloseHost() { _window.Close(); }
diff --git a/ui/Countly.UI.WebView2/WidgetMessageParser.cs b/ui/Countly.UI.WebView2/WidgetMessageParser.cs
new file mode 100644
index 00000000..2a88f504
--- /dev/null
+++ b/ui/Countly.UI.WebView2/WidgetMessageParser.cs
@@ -0,0 +1,69 @@
+using CountlySDK.CountlyCommon;
+using Newtonsoft.Json.Linq;
+
+namespace CountlySDK.UI
+{
+ ///
+ /// Parses the widget's postMessage payloads (bridged to the host) — the
+ /// {cly_widget_command, action:'resize_me', resize_me:{p,l}} / {close} shape.
+ /// Distinct from the URL-based (which handles
+ /// countly_action_event navigations). A rect is only usable when it has positive w AND h.
+ /// Uses Newtonsoft (the SDK's JSON library) so it builds on net462 as well as net8.0.
+ ///
+ public static class WidgetMessageParser
+ {
+ public static bool TryParse(string json, out WidgetAction action)
+ {
+ action = new WidgetAction();
+ if (string.IsNullOrEmpty(json)) { return false; }
+
+ JToken token;
+ try { token = JToken.Parse(json); }
+ catch { return false; }
+
+ if (!(token is JObject root)) { return false; }
+ if (root.Property("cly_widget_command") == null) { return false; }
+
+ action.IsActionEvent = true;
+
+ JToken close = root["close"];
+ if (close != null && IsTruthy(close)) {
+ action.Close = true;
+ }
+
+ if (root["resize_me"] is JObject rm) {
+ WidgetRect p = ReadRect(rm, "p");
+ WidgetRect l = ReadRect(rm, "l");
+ if (p != null || l != null) {
+ action.HasResize = true;
+ action.Portrait = p;
+ action.Landscape = l;
+ }
+ }
+ return true;
+ }
+
+ private static WidgetRect ReadRect(JObject resizeMe, string key)
+ {
+ if (!(resizeMe[key] is JObject e)) { return null; }
+ int w = ReadInt(e, "w");
+ int h = ReadInt(e, "h");
+ if (w <= 0 || h <= 0) { return null; }
+ return new WidgetRect { X = ReadInt(e, "x"), Y = ReadInt(e, "y"), W = w, H = h };
+ }
+
+ private static int ReadInt(JObject obj, string name)
+ {
+ JToken v = obj[name];
+ if (v != null && (v.Type == JTokenType.Integer || v.Type == JTokenType.Float)) { return (int)v; }
+ return 0;
+ }
+
+ private static bool IsTruthy(JToken e)
+ {
+ if (e.Type == JTokenType.Boolean) { return (bool)e; }
+ if (e.Type == JTokenType.Integer || e.Type == JTokenType.Float) { return (double)e != 0; }
+ return false;
+ }
+ }
+}
diff --git a/ui/Countly.UI.WebView2/WidgetPlacement.cs b/ui/Countly.UI.WebView2/WidgetPlacement.cs
new file mode 100644
index 00000000..50f06b46
--- /dev/null
+++ b/ui/Countly.UI.WebView2/WidgetPlacement.cs
@@ -0,0 +1,38 @@
+using System;
+using CountlySDK.CountlyCommon;
+
+namespace CountlySDK.UI
+{
+ /// The coordinate space we hand the widget (DIPs, screen-absolute origin).
+ public struct WidgetSurface
+ {
+ public int X;
+ public int Y;
+ public int Width;
+ public int Height;
+ }
+
+ ///
+ /// Pure mapping from a widget resize request (CSS px relative to the surface) to an on-screen
+ /// window rect (DIPs, screen-absolute). CSS px == DIPs under per-monitor DPI awareness.
+ ///
+ public static class WidgetPlacement
+ {
+ public static WidgetRect Resolve(WidgetAction action, WidgetSurface surface)
+ {
+ if (action == null || !action.HasResize) { return null; }
+
+ bool landscape = surface.Width >= surface.Height;
+ WidgetRect r = landscape ? (action.Landscape ?? action.Portrait) : (action.Portrait ?? action.Landscape);
+ if (r == null) { return null; }
+
+ int w = Math.Min(r.W, surface.Width);
+ int h = Math.Min(r.H, surface.Height);
+ int x = surface.X + Clamp(r.X, 0, surface.Width - w);
+ int y = surface.Y + Clamp(r.Y, 0, surface.Height - h);
+ return new WidgetRect { X = x, Y = y, W = w, H = h };
+ }
+
+ private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v);
+ }
+}
diff --git a/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs b/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs
index d5b1d65f..1a1213ac 100644
--- a/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs
+++ b/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs
@@ -1,6 +1,28 @@
+using System;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
namespace CountlyFeedbackDemo.Wpf
{
public partial class App : System.Windows.Application
{
}
+
+ internal static class DpiBootstrap
+ {
+ // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (-4). Set at module load — before WPF
+ // initializes and locks the process DPI awareness — so WebView2 content renders 1:1
+ // with the window instead of being mis-scaled/clipped. Windows 10 1703+.
+ private static readonly IntPtr PerMonitorAwareV2 = new IntPtr(-4);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetProcessDpiAwarenessContext(IntPtr value);
+
+ [ModuleInitializer]
+ internal static void Init()
+ {
+ try { SetProcessDpiAwarenessContext(PerMonitorAwareV2); }
+ catch { /* pre-1703 Windows: fall back to the embedded manifest */ }
+ }
+ }
}
diff --git a/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj b/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj
index 4117e2f1..b9544162 100644
--- a/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj
+++ b/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj
@@ -6,6 +6,7 @@
disable
false
false
+ app.manifest
diff --git a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
index e51f5612..88c53e32 100644
--- a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
+++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
@@ -47,10 +47,13 @@ private void ShowBtn_Click(object sender, RoutedEventArgs e)
CountlyWebView.PresentFeedbackWidget(this, _widgets[i], () => StatusText.Text = "Widget closed.");
}
- private void ContentBtn_Click(object sender, RoutedEventArgs e)
+ private async void ContentBtn_Click(object sender, RoutedEventArgs e)
{
+ // The content item triggers on any view -> record one so the server marks content
+ // available for this device, then enter the zone (first fetch ~4s later).
+ await CountlySDK.Countly.Instance.RecordView("test_view");
CountlyWebView.EnableContentZone();
- StatusText.Text = "Content zone enabled (polling). Server must have active content for this device.";
+ StatusText.Text = "Recorded view + content zone enabled (first fetch ~4s). Waiting for server content...";
}
}
}
diff --git a/ui/CountlyFeedbackDemo.Wpf/app.manifest b/ui/CountlyFeedbackDemo.Wpf/app.manifest
new file mode 100644
index 00000000..683b69ba
--- /dev/null
+++ b/ui/CountlyFeedbackDemo.Wpf/app.manifest
@@ -0,0 +1,12 @@
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+
+
From 43baf01c3e128cd63940992b9bf33b1846edc348 Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Mon, 20 Jul 2026 16:41:31 +0900
Subject: [PATCH 7/7] ci
---
.github/workflows/release-ui.yml | 61 +++++++++++++++++++
.../Countly.UI.WebView2.csproj | 48 +++++++++++++--
.../FeedbackWidgetPresenter.cs | 1 +
ui/Countly.UI.WebView2/README.md | 58 ++++++++++++++++++
ui/Countly.UI.WebView2/WidgetMessageParser.cs | 5 ++
ui/Countly.UI.WebView2/WidgetPlacement.cs | 8 +++
6 files changed, 176 insertions(+), 5 deletions(-)
create mode 100644 .github/workflows/release-ui.yml
create mode 100644 ui/Countly.UI.WebView2/README.md
diff --git a/.github/workflows/release-ui.yml b/.github/workflows/release-ui.yml
new file mode 100644
index 00000000..5f169556
--- /dev/null
+++ b/.github/workflows/release-ui.yml
@@ -0,0 +1,61 @@
+name: Release UI package
+
+# Publishes the Countly.UI.WebView2 NuGet package.
+#
+# Trigger manually (Actions -> Release UI package -> Run workflow) or by pushing a tag like
+# `ui-v26.1.0`. The package version comes from the csproj (), not the tag.
+#
+# PREREQUISITES:
+# 1. The core `Countly` package at the version referenced by the UI csproj (26.1.0) must ALREADY
+# be published to nuget.org — the release build restores it (`-p:UsePackagedCountly=true`).
+# 2. Repo secrets:
+# - NUGET_API_KEY : nuget.org push key
+# - SIGNING_PFX_BASE64 : base64 of CountlyWinSDKStrongNameKey.pfx (strong-name key)
+# - SIGNING_PFX_PASSWORD : password for that .pfx (omit/blank if the key is passwordless)
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - 'ui-v*'
+
+jobs:
+ release:
+ name: Pack + push Countly.UI.WebView2
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore strong-name key
+ shell: pwsh
+ run: |
+ $pfx = "netstd/Countly/CountlyWinSDKStrongNameKey.pfx"
+ [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String("${{ secrets.SIGNING_PFX_BASE64 }}"))
+ Write-Host "Wrote signing key to $pfx"
+
+ - name: Pack (Release, signed, against the published core)
+ shell: pwsh
+ run: >
+ dotnet pack ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj
+ -c Release
+ -p:UsePackagedCountly=true
+ -p:SignAssembly=true
+ -p:DelaySign=false
+ -o ${{ github.workspace }}/artifacts
+
+ - name: Push to NuGet
+ shell: pwsh
+ run: >
+ dotnet nuget push "${{ github.workspace }}/artifacts/*.nupkg"
+ --api-key ${{ secrets.NUGET_API_KEY }}
+ --source https://api.nuget.org/v3/index.json
+ --skip-duplicate
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nupkg
+ path: ${{ github.workspace }}/artifacts/*.nupkg
diff --git a/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj b/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj
index abea2e2d..4115cdb8 100644
--- a/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj
+++ b/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj
@@ -6,15 +6,53 @@
CountlySDK.UI
Countly.UI.WebView2
latest
- false
- false
+
+
+ true
+ ..\..\netstd\Countly\CountlyWinSDKStrongNameKey.pfx
+ false
+
+
+ true
+
+
+ Countly.UI.WebView2
+ 26.1.0
+ 26.1.0.0
+ 26.1.0.0
+ Countly Feedback Widgets and Content UI (WebView2)
+ Countly
+ Countly
+ Countly SDK
+ WebView2-based WPF/WinForms UI for the Countly Windows SDK: displays Feedback Widgets (Surveys, NPS, Ratings) and the Content feature. Companion to the Countly core package. Requires the Microsoft Edge WebView2 Evergreen runtime on the end-user machine.
+ (C) Countly
+ countly;analytics;feedback;surveys;nps;rating;content;webview2;wpf;winforms
+ https://github.com/Countly/countly-sdk-windows
+ https://github.com/Countly/countly-sdk-windows
+ git
+ MIT
+ icon.png
+ README.md
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs b/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
index 6242c6eb..ecc7af4d 100644
--- a/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
+++ b/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
@@ -17,6 +17,7 @@ public class FeedbackWidgetPresenter
private CountlyFeedbackWidget _widget;
private bool _surfaceReported;
+ /// Creates a presenter bound to a host, the core feedback module, and a dismissal callback.
public FeedbackWidgetPresenter(IWidgetWebHost host, Feedback feedback, Action onClosed)
{
_host = host;
diff --git a/ui/Countly.UI.WebView2/README.md b/ui/Countly.UI.WebView2/README.md
new file mode 100644
index 00000000..d9a90d1e
--- /dev/null
+++ b/ui/Countly.UI.WebView2/README.md
@@ -0,0 +1,58 @@
+# Countly.UI.WebView2
+
+WebView2-based WPF / WinForms UI for the [Countly Windows SDK](https://github.com/Countly/countly-sdk-windows).
+It renders **Feedback Widgets** (Surveys, NPS, Ratings) and the **Content** feature on the desktop.
+It is a thin companion to the core `Countly` package — the core drives analytics; this package only
+displays.
+
+## Requirements
+
+- **Microsoft Edge WebView2 Evergreen Runtime** must be installed on the end-user machine. It ships
+ with recent Windows 10/11 and Microsoft Edge, but is not guaranteed on every machine — distribute
+ the [Evergreen Bootstrapper/Standalone installer](https://developer.microsoft.com/microsoft-edge/webview2/)
+ if you need to guarantee it. The SDK degrades gracefully (no-ops) when the runtime is absent.
+- `Countly` core package (a matching version is a dependency of this package).
+- .NET Framework 4.6.2+ or .NET 8 (`net462` / `net8.0-windows`), WPF or WinForms.
+
+## DPI awareness
+
+For crisp, correctly-placed widgets and content, make the host process **per-monitor DPI aware**
+(an `app.manifest` `dpiAwareness` entry is the standard way). The SDK measures and compensates for
+DPI at runtime, so it also works on DPI-unaware hosts, but per-monitor awareness is recommended.
+
+## Usage
+
+Initialize the core SDK as usual, then:
+
+### Feedback widgets
+
+```csharp
+using CountlySDK.UI;
+
+// Fetch available widgets via the core, then present one:
+var widgets = await Countly.Instance.Feedback().GetAvailableFeedbackWidgets();
+
+// WPF (call on the UI thread; owner is your Window):
+CountlyWebView.PresentFeedbackWidget(this, widgets[0], onClosed: () => { /* dismissed */ });
+```
+
+The card sizes and positions itself where the widget asks (a screen corner by default, or within the
+app window if `CountlyWebView.ShowWidgetsWithinApp = true`).
+
+### Content
+
+```csharp
+using CountlySDK.UI;
+
+// Registers the display bridge and starts polling for content (experimental):
+CountlyWebView.EnableContentZone();
+// ...
+CountlyWebView.DisableContentZone();
+```
+
+Content interactions (events, external links, resize, close) are handled automatically; emitted
+events are recorded through the core and the queue is flushed so the server can react immediately.
+
+## License
+
+MIT — see the [LICENSE](https://github.com/Countly/countly-sdk-windows/blob/master/LICENSE).
diff --git a/ui/Countly.UI.WebView2/WidgetMessageParser.cs b/ui/Countly.UI.WebView2/WidgetMessageParser.cs
index 2a88f504..44f80d58 100644
--- a/ui/Countly.UI.WebView2/WidgetMessageParser.cs
+++ b/ui/Countly.UI.WebView2/WidgetMessageParser.cs
@@ -12,6 +12,11 @@ namespace CountlySDK.UI
///
public static class WidgetMessageParser
{
+ ///
+ /// Parses a bridged widget postMessage payload into a . Returns
+ /// true if the JSON is a recognised cly_widget_command message (with
+ /// populated), false otherwise.
+ ///
public static bool TryParse(string json, out WidgetAction action)
{
action = new WidgetAction();
diff --git a/ui/Countly.UI.WebView2/WidgetPlacement.cs b/ui/Countly.UI.WebView2/WidgetPlacement.cs
index 50f06b46..592d5553 100644
--- a/ui/Countly.UI.WebView2/WidgetPlacement.cs
+++ b/ui/Countly.UI.WebView2/WidgetPlacement.cs
@@ -6,9 +6,13 @@ namespace CountlySDK.UI
/// The coordinate space we hand the widget (DIPs, screen-absolute origin).
public struct WidgetSurface
{
+ /// Left edge of the surface, screen-absolute (DIPs).
public int X;
+ /// Top edge of the surface, screen-absolute (DIPs).
public int Y;
+ /// Surface width (DIPs).
public int Width;
+ /// Surface height (DIPs).
public int Height;
}
@@ -18,6 +22,10 @@ public struct WidgetSurface
///
public static class WidgetPlacement
{
+ ///
+ /// Resolves the widget's requested rect (orientation-appropriate, clamped to the surface)
+ /// into a screen-absolute window rect, or null if the action carries no usable rect.
+ ///
public static WidgetRect Resolve(WidgetAction action, WidgetSurface surface)
{
if (action == null || !action.HasResize) { return null; }