diff --git a/SecRandom.PluginSdk/IExternalStudentDrawService.cs b/SecRandom.PluginSdk/IExternalStudentDrawService.cs
new file mode 100644
index 00000000..a334807f
--- /dev/null
+++ b/SecRandom.PluginSdk/IExternalStudentDrawService.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using SecRandom.Shared.Models.Profile;
+
+namespace SecRandom.PluginSdk;
+
+///
+/// Host-neutral student draw boundary for plugins and other external integrations.
+/// The transport and authentication policy belong to the integration that consumes it.
+///
+public sealed record ExternalStudentDrawRequest
+{
+ public string Mode { get; init; } = "result_only";
+ public int Count { get; init; } = 1;
+ public string Gender { get; init; } = string.Empty;
+ public IReadOnlyList IncludeTags { get; init; } = [];
+ public IReadOnlyList ExcludeTags { get; init; } = [];
+ public IReadOnlyList IncludeIds { get; init; } = [];
+ public IReadOnlyList IncludeNames { get; init; } = [];
+}
+
+public sealed record ExternalStudentDrawResult(
+ string Mode,
+ string Status,
+ string Profile,
+ IReadOnlyList Students);
+
+public interface IExternalStudentDrawService
+{
+ Task DrawAsync(
+ ExternalStudentDrawRequest request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/SecRandom.PluginSdk/README.md b/SecRandom.PluginSdk/README.md
index b464f5a2..5ce705cd 100644
--- a/SecRandom.PluginSdk/README.md
+++ b/SecRandom.PluginSdk/README.md
@@ -32,6 +32,12 @@ Published plugins reference the SDK package and exclude its runtime assets so th
The repository template defaults `UseLocalPluginSdk=true` so solution builds work before the SDK is published; set `UseLocalPluginSdk=false` with a NuGet source that contains `SecRandom.PluginSdk` to exercise the release packaging path.
+## Host integration APIs
+
+`IExternalStudentDrawService` lets a transport plugin request a host-managed student draw
+without depending on desktop application's internal service types. The host owns drawing,
+temporary records, history, security, and notifications; the plugin owns its transport.
+
## Building a plugin package
Set `true` to produce `srpx/.srpx` after every build. The package is a ZIP whose root contains `manifest.yml`, the entrance assembly, and any external package dependencies. Place it in `data/cache/plugin-packages` and restart the desktop application to install.
diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs
index 39f5124c..97bae73b 100644
--- a/SecRandom/App.axaml.cs
+++ b/SecRandom/App.axaml.cs
@@ -59,7 +59,6 @@
using SecRandom.Services.Music;
using SecRandom.Services.Settings;
using SecRandom.Services.Security;
-using SecRandom.Services.SecAgent;
using SecRandom.Services.Telemetry;
using SecRandom.Services.Verification;
using SecRandom.Services.Voice;
@@ -844,9 +843,7 @@ private void BuildHost(IPlatformServiceRoot platform)
services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService());
services.AddSingleton();
- // Local-only REST endpoint for the SecAgent connector. It intentionally has no UI/settings registration.
- services.AddHostedService();
- services.AddHostedService();
+ services.AddSingleton();
services.AddSingleton(serviceProvider => new MusicLibraryService(
serviceProvider.GetRequiredService(),
serviceProvider.GetRequiredService>(),
diff --git a/SecRandom/Services/Draw/ExternalStudentDrawService.cs b/SecRandom/Services/Draw/ExternalStudentDrawService.cs
new file mode 100644
index 00000000..816c56e8
--- /dev/null
+++ b/SecRandom/Services/Draw/ExternalStudentDrawService.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using SecRandom.Core.Abstraction.Services;
+using SecRandom.Core.Enums;
+using SecRandom.Core.Enums.Configs;
+using SecRandom.Core.Models.Draw;
+using SecRandom.Core.Services.Config;
+using SecRandom.Core.Services.Draw;
+using SecRandom.PluginSdk;
+using SecRandom.Services.Linkage;
+using SecRandom.Services.Notification;
+using SecRandom.Shared.Extensions;
+using SecRandom.Shared.Models.Profile;
+
+namespace SecRandom.Services.Draw;
+
+///
+/// Implements the host-neutral external student draw boundary.
+/// Transport-specific integrations, such as the SecAgent plugin, call this service.
+///
+public sealed class ExternalStudentDrawService(
+ IProfileService profileService,
+ MainConfigHandler configHandler,
+ IDrawTemporaryRecordService temporaryRecordService,
+ DrawEngine drawEngine,
+ LinkageDrawCoordinator linkageDrawCoordinator,
+ NotificationService notificationService) : IExternalStudentDrawService
+{
+ public async Task DrawAsync(
+ ExternalStudentDrawRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request.Mode is not ("flash" or "result_only"))
+ throw new ArgumentException("mode must be flash or result_only.", nameof(request));
+
+ var requestedCount = Math.Clamp(request.Count, 1, 100);
+ if (request.Mode == "flash")
+ requestedCount = 1;
+
+ var gender = request.Gender?.Trim() ?? string.Empty;
+ var listName = profileService.CurrentStudentList?.Name ?? string.Empty;
+ var temporaryCounts = temporaryRecordService.GetStudentCounts(listName, gender, string.Empty);
+ var hasMatchingStudents = (profileService.CurrentStudentList?.Students ?? [])
+ .Any(student => student.IsCandidate && Matches(student, gender, request));
+ var mayResetExhaustedRound = configHandler.Data.QuickDrawSettings.DrawMode != DrawMode.Repeat
+ && hasMatchingStudents;
+
+ var result = await InvokeAuthorizedAsync(
+ SecurityOperation.QuickDrawStart,
+ async () =>
+ {
+ DrawResult DrawFromRemainingStudents()
+ => drawEngine.DrawStudent(
+ requestedCount,
+ student => Matches(student, gender, request)
+ && !HasReachedTemporaryLimit(student, temporaryCounts),
+ DrawSettingsType.QuickDraw,
+ linkageDrawCoordinator.GetCourseName());
+
+ var draw = DrawFromRemainingStudents();
+ if (!draw.IsSuccess
+ && mayResetExhaustedRound
+ && draw.Status == DrawStatus.RepeatLimitExhausted)
+ {
+ profileService.ClearCurrentStudentHistory();
+ temporaryRecordService.ResetStudentList(listName);
+ temporaryCounts = temporaryRecordService.GetStudentCounts(listName, gender, string.Empty);
+ draw = DrawFromRemainingStudents();
+ }
+
+ if (!draw.IsSuccess || draw.Result.Count == 0)
+ return draw;
+
+ profileService.RecordStudentHistory(
+ draw.Result,
+ DateTime.Now,
+ requestedCount,
+ drawMethod: (int)configHandler.Data.QuickDrawSettings.DrawType,
+ courseName: linkageDrawCoordinator.GetCourseName());
+ temporaryRecordService.RecordStudents(listName, gender, string.Empty, draw.Result);
+ if (request.Mode == "flash")
+ notificationService.QueueStudents(
+ NotificationSettingsType.QuickDraw,
+ linkageDrawCoordinator.GetCourseName(),
+ draw.Result);
+ return draw;
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ return new ExternalStudentDrawResult(
+ request.Mode,
+ result.Status.ToString(),
+ listName,
+ result.Result);
+ }
+
+ private async Task> InvokeAuthorizedAsync(
+ SecurityOperation operation,
+ Func>> action,
+ CancellationToken cancellationToken)
+ {
+ DrawResult? result = null;
+ var authorized = await linkageDrawCoordinator.AuthorizeAsync(
+ operation,
+ async () => result = await action().ConfigureAwait(false),
+ cancellationToken).ConfigureAwait(false);
+ return authorized && result is not null
+ ? result
+ : new DrawResult { Status = DrawStatus.Failure };
+ }
+
+ private bool HasReachedTemporaryLimit(
+ Student student,
+ IReadOnlyDictionary temporaryCounts)
+ {
+ var settings = configHandler.Data.QuickDrawSettings;
+ var threshold = settings.DrawMode switch
+ {
+ DrawMode.Repeat => 0,
+ DrawMode.NoRepeat => 1,
+ DrawMode.HalfRepeat => Math.Max(1, settings.HalfRepeat),
+ _ => 1
+ };
+ return threshold > 0
+ && temporaryCounts.GetValueOrDefault(ProfileRecordIdentity.EnsureRecordId(student)) >= threshold;
+ }
+
+ private static bool Matches(
+ Student student,
+ string gender,
+ ExternalStudentDrawRequest request)
+ {
+ var tags = student.Tags.Split(
+ [',', ';', ' '],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ return (string.IsNullOrWhiteSpace(gender)
+ || string.Equals(student.Gender, gender, StringComparison.OrdinalIgnoreCase))
+ && request.IncludeTags.All(tag => tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
+ && request.ExcludeTags.All(tag => !tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
+ && (request.IncludeIds.Count == 0
+ || request.IncludeIds.Contains(student.Id, StringComparer.OrdinalIgnoreCase))
+ && (request.IncludeNames.Count == 0
+ || request.IncludeNames.Contains(student.Name, StringComparer.OrdinalIgnoreCase));
+ }
+}
diff --git a/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs b/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs
deleted file mode 100644
index 3863c987..00000000
--- a/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs
+++ /dev/null
@@ -1,294 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Net;
-using System.Text;
-using System.Text.Json;
-using System.Text.Json.Nodes;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using SecRandom.Core.Abstraction.Services;
-using SecRandom.Core.Enums;
-using SecRandom.Core.Enums.Configs;
-using SecRandom.Core.Models.Draw;
-using SecRandom.Core.Services.Config;
-using SecRandom.Core.Services.Draw;
-using SecRandom.Services.Draw;
-using SecRandom.Services.Linkage;
-using SecRandom.Services.Notification;
-using SecRandom.Services.Security;
-using SecRandom.Shared.Extensions;
-using SecRandom.Shared.Models.Profile;
-
-namespace SecRandom.Services.SecAgent;
-
-///
-/// Loopback-only REST endpoint for the local SecAgent connector.
-/// SecRandom intentionally exposes ordinary HTTP/JSON here; tool discovery and hidden-tool
-/// behavior belong to the SecAgent plugin.
-///
-public sealed class SecAgentHttpHostedService(
- ILogger logger,
- IProfileService profileService,
- MainConfigHandler configHandler,
- IDrawTemporaryRecordService temporaryRecordService,
- DrawEngine drawEngine,
- LinkageDrawCoordinator linkageDrawCoordinator,
- NotificationService notificationService) : BackgroundService
-{
- private const string Prefix = "http://127.0.0.1:3910/api/secagent/v1/";
- private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
- private readonly HttpListener _listener = new();
-
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- _listener.Prefixes.Add(Prefix);
- try
- {
- _listener.Start();
- logger.LogInformation("SecAgent loopback REST endpoint started at {Prefix}.", Prefix[..^1]);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Failed to start SecAgent loopback REST endpoint at {Prefix}.", Prefix);
- return;
- }
-
- try
- {
- while (!stoppingToken.IsCancellationRequested)
- {
- var context = await _listener.GetContextAsync().WaitAsync(stoppingToken).ConfigureAwait(false);
- _ = Task.Run(() => HandleAsync(context, stoppingToken), CancellationToken.None);
- }
- }
- catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
- {
- }
- catch (HttpListenerException) when (stoppingToken.IsCancellationRequested)
- {
- }
- finally
- {
- _listener.Stop();
- _listener.Close();
- }
- }
-
- public override Task StopAsync(CancellationToken cancellationToken)
- {
- if (_listener.IsListening)
- _listener.Stop();
- return base.StopAsync(cancellationToken);
- }
-
- private async Task HandleAsync(HttpListenerContext context, CancellationToken cancellationToken)
- {
- try
- {
- var path = context.Request.Url?.AbsolutePath.TrimEnd('/') ?? string.Empty;
- var method = context.Request.HttpMethod.ToUpperInvariant();
- JsonNode result;
-
- if (method == "GET" && path == "/api/secagent/v1/students")
- result = ListStudents();
- else if (method == "POST" && path == "/api/secagent/v1/students")
- result = UpsertStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false));
- else if (method == "DELETE" && path == "/api/secagent/v1/students")
- result = RemoveStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false));
- else if (method == "POST" && path == "/api/secagent/v1/draw/students")
- result = await DrawStudentsAsync(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
- else
- {
- context.Response.StatusCode = (int)HttpStatusCode.NotFound;
- result = new JsonObject { ["error"] = "Endpoint not found." };
- }
-
- await WriteJsonAsync(context.Response, result, cancellationToken).ConfigureAwait(false);
- }
- catch (ArgumentException ex)
- {
- await WriteErrorAsync(context.Response, HttpStatusCode.BadRequest, ex.Message).ConfigureAwait(false);
- }
- catch (InvalidOperationException ex)
- {
- await WriteErrorAsync(context.Response, HttpStatusCode.Conflict, ex.Message).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "SecAgent REST request failed.");
- await WriteErrorAsync(context.Response, HttpStatusCode.InternalServerError, "SecRandom request failed.").ConfigureAwait(false);
- }
- finally
- {
- context.Response.Close();
- }
- }
-
- private JsonObject ListStudents()
- {
- var list = profileService.CurrentStudentList;
- return new JsonObject
- {
- ["profile"] = list?.Name ?? string.Empty,
- ["students"] = new JsonArray((list?.Students ?? []).Select(ToJson).ToArray())
- };
- }
-
- private JsonObject UpsertStudent(JsonObject arguments)
- {
- var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile.");
- var recordId = ParseGuid(arguments["record_id"]?.GetValue());
- var id = StringArgument(arguments, "id");
- var student = recordId is not null ? list.Students.FirstOrDefault(item => item.RecordId == recordId) : null;
- student ??= !string.IsNullOrWhiteSpace(id) ? list.Students.FirstOrDefault(item => item.Id == id) : null;
- if (student is null)
- {
- student = new Student { RecordId = recordId ?? Guid.NewGuid() };
- list.Students.Add(student);
- }
-
- student.Id = id;
- student.Name = StringArgument(arguments, "name");
- student.Group = StringArgument(arguments, "group");
- student.Gender = StringArgument(arguments, "gender");
- student.Tags = StringArgument(arguments, "tags");
- student.Exists = arguments["exists"]?.GetValue() ?? true;
- if (!student.IsCandidate)
- throw new ArgumentException("Student requires a nonblank id or name.");
- profileService.SaveProfile();
- return new JsonObject { ["student"] = ToJson(student), ["profile"] = list.Name };
- }
-
- private JsonObject RemoveStudent(JsonObject arguments)
- {
- var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile.");
- var recordId = ParseGuid(arguments["record_id"]?.GetValue());
- var id = StringArgument(arguments, "id");
- var name = StringArgument(arguments, "name");
- var matches = list.Students.Where(item =>
- (recordId is not null && item.RecordId == recordId)
- || (!string.IsNullOrWhiteSpace(id) && item.Id == id)
- || (!string.IsNullOrWhiteSpace(name) && item.Name == name)).ToList();
- if (matches.Count != 1)
- throw new InvalidOperationException(matches.Count == 0 ? "Student was not found." : "Student selector matched more than one student.");
- list.Students.Remove(matches[0]);
- profileService.SaveProfile();
- return new JsonObject { ["removed"] = ToJson(matches[0]), ["profile"] = list.Name };
- }
-
- private async Task DrawStudentsAsync(JsonObject arguments, CancellationToken cancellationToken)
- {
- var mode = StringArgument(arguments, "mode");
- if (mode is not ("flash" or "result_only"))
- throw new ArgumentException("mode must be flash or result_only.");
-
- var requestedCount = Math.Clamp(arguments["count"]?.GetValue() ?? 1, 1, 100);
- if (mode == "flash") requestedCount = 1;
- var includeTags = StringArray(arguments, "include_tags");
- var excludeTags = StringArray(arguments, "exclude_tags");
- var includeIds = StringArray(arguments, "include_ids");
- var includeNames = StringArray(arguments, "include_names");
- var listName = profileService.CurrentStudentList?.Name ?? string.Empty;
- var temporaryCounts = temporaryRecordService.GetStudentCounts(listName, string.Empty, string.Empty);
-
- var result = await InvokeAuthorizedAsync(SecurityOperation.QuickDrawStart, () =>
- {
- var draw = drawEngine.DrawStudent(requestedCount, student => Matches(student, includeTags, excludeTags, includeIds, includeNames)
- && !HasReachedTemporaryLimit(student, temporaryCounts), DrawSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName());
- if (!draw.IsSuccess || draw.Result.Count == 0)
- return Task.FromResult(draw);
-
- profileService.RecordStudentHistory(draw.Result, DateTime.Now, requestedCount,
- drawMethod: (int)configHandler.Data.QuickDrawSettings.DrawType,
- courseName: linkageDrawCoordinator.GetCourseName());
- temporaryRecordService.RecordStudents(listName, string.Empty, string.Empty, draw.Result);
- if (mode == "flash")
- notificationService.QueueStudents(NotificationSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName(), draw.Result);
- return Task.FromResult(draw);
- }, cancellationToken).ConfigureAwait(false);
-
- return new JsonObject
- {
- ["mode"] = mode,
- ["count"] = result.Result.Count,
- ["status"] = result.Status.ToString(),
- ["profile"] = listName,
- ["students"] = new JsonArray(result.Result.Select(ToJson).ToArray())
- };
- }
-
- private async Task> InvokeAuthorizedAsync(SecurityOperation operation, Func>> action, CancellationToken cancellationToken)
- {
- DrawResult? result = null;
- var authorized = await linkageDrawCoordinator.AuthorizeAsync(operation,
- async () => result = await action().ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
- return authorized && result is not null ? result : new DrawResult { Status = DrawStatus.Failure };
- }
-
- private bool HasReachedTemporaryLimit(Student student, IReadOnlyDictionary temporaryCounts)
- {
- var settings = configHandler.Data.QuickDrawSettings;
- var threshold = settings.DrawMode switch
- {
- DrawMode.Repeat => 0,
- DrawMode.NoRepeat => 1,
- DrawMode.HalfRepeat => Math.Max(1, settings.HalfRepeat),
- _ => 1
- };
- return threshold > 0 && temporaryCounts.GetValueOrDefault(ProfileRecordIdentity.EnsureRecordId(student)) >= threshold;
- }
-
- private static bool Matches(Student student, IReadOnlyCollection includeTags, IReadOnlyCollection excludeTags,
- IReadOnlyCollection includeIds, IReadOnlyCollection includeNames)
- {
- var tags = student.Tags.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
- return includeTags.All(tag => tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
- && excludeTags.All(tag => !tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
- && (includeIds.Count == 0 || includeIds.Contains(student.Id, StringComparer.OrdinalIgnoreCase))
- && (includeNames.Count == 0 || includeNames.Contains(student.Name, StringComparer.OrdinalIgnoreCase));
- }
-
- private static JsonObject ToJson(Student student) => new()
- {
- ["record_id"] = ProfileRecordIdentity.EnsureRecordId(student),
- ["id"] = student.Id,
- ["name"] = student.Name,
- ["group"] = student.Group,
- ["gender"] = student.Gender,
- ["tags"] = student.Tags,
- ["exists"] = student.Exists
- };
-
- private static async Task ReadBodyAsync(HttpListenerRequest request, CancellationToken cancellationToken)
- {
- var body = await JsonNode.ParseAsync(request.InputStream, cancellationToken: cancellationToken).ConfigureAwait(false) as JsonObject;
- return body ?? throw new ArgumentException("Request body must be a JSON object.");
- }
-
- private static string StringArgument(JsonObject arguments, string name) => arguments[name]?.GetValue()?.Trim() ?? string.Empty;
- private static Guid? ParseGuid(string? value) => Guid.TryParse(value, out var result) ? result : null;
-
- private static IReadOnlyList StringArray(JsonObject arguments, string name)
- => arguments[name] is JsonArray array
- ? array.Select(item => item?.GetValue()?.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Cast().ToArray()
- : [];
-
- private static async Task WriteJsonAsync(HttpListenerResponse response, JsonNode value, CancellationToken cancellationToken)
- {
- var bytes = Encoding.UTF8.GetBytes(value.ToJsonString(JsonOptions));
- response.ContentType = "application/json";
- response.ContentEncoding = Encoding.UTF8;
- response.ContentLength64 = bytes.Length;
- await response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
- }
-
- private static Task WriteErrorAsync(HttpListenerResponse response, HttpStatusCode status, string message)
- {
- response.StatusCode = (int)status;
- return WriteJsonAsync(response, new JsonObject { ["error"] = message }, CancellationToken.None);
- }
-}
diff --git a/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs
deleted file mode 100644
index daa45dcc..00000000
--- a/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Net.Http.Json;
-using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-
-namespace SecRandom.Services.SecAgent;
-
-///
-/// Quietly asks a running local SecAgent to install the SecRandom connector.
-/// This is deliberately best-effort: SecRandom remains fully usable without SecAgent.
-///
-public sealed class SecAgentPluginBootstrapHostedService(
- IHttpClientFactory httpClientFactory,
- ILogger logger) : BackgroundService
-{
- private const string ConnectorPluginId = "secrandom";
- private const string ConnectorPluginVersion = "0.1.1";
- private static readonly Uri BaseUri = new("http://127.0.0.1:42189/");
- private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
-
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- // Give the desktop host time to finish its own startup, and never hold up the UI.
- try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken).ConfigureAwait(false); }
- catch (OperationCanceledException) { return; }
-
- while (!stoppingToken.IsCancellationRequested)
- {
- try
- {
- await EnsurePluginAsync(stoppingToken).ConfigureAwait(false);
- return;
- }
- catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
- {
- return;
- }
- catch (HttpRequestException)
- {
- // SecAgent is optional and may simply not be installed/running.
- }
- catch (Exception ex)
- {
- logger.LogDebug(ex, "SecAgent connector bootstrap was skipped.");
- return;
- }
-
- try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false); }
- catch (OperationCanceledException) { return; }
- }
- }
-
- private async Task EnsurePluginAsync(CancellationToken cancellationToken)
- {
- using var client = httpClientFactory.CreateClient();
- client.BaseAddress = BaseUri;
- client.Timeout = TimeSpan.FromSeconds(2);
-
- using var health = await client.GetAsync("health", cancellationToken).ConfigureAwait(false);
- if (!health.IsSuccessStatusCode) return;
-
- var installed = await client.GetFromJsonAsync("plugins", JsonOptions, cancellationToken).ConfigureAwait(false);
- var current = installed?.Plugins?.FirstOrDefault(plugin =>
- string.Equals(plugin.Id, ConnectorPluginId, StringComparison.OrdinalIgnoreCase));
- if (current is not null && !IsOlderVersion(current.Version, ConnectorPluginVersion))
- return;
-
- var request = current is null
- ? new { pluginId = ConnectorPluginId, version = (string?)null }
- : new { pluginId = ConnectorPluginId, version = (string?)ConnectorPluginVersion };
- using var response = await client.PostAsJsonAsync("plugins/install", request, JsonOptions, cancellationToken).ConfigureAwait(false);
- if (response.IsSuccessStatusCode)
- logger.LogInformation("Requested local SecAgent to install/update the SecRandom connector plugin to {Version}.", ConnectorPluginVersion);
- else
- logger.LogDebug("Local SecAgent declined SecRandom connector installation with HTTP {StatusCode}.", response.StatusCode);
- }
-
- private sealed class PluginListResponse
- {
- public List? Plugins { get; init; }
- }
-
- private sealed class PluginInfo
- {
- public string? Id { get; init; }
- public string? Version { get; init; }
- }
-
- private static bool IsOlderVersion(string? current, string desired)
- {
- if (Version.TryParse(current, out var currentVersion) && Version.TryParse(desired, out var desiredVersion))
- return currentVersion < desiredVersion;
-
- return !string.Equals(current, desired, StringComparison.OrdinalIgnoreCase);
- }
-}
diff --git a/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs b/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs
index d2a41fff..c50bd11c 100644
--- a/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs
+++ b/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs
@@ -435,6 +435,7 @@ private bool ResetForNewRoundIfExhausted()
if (DrawCandidateFilter.FilterEligibleStudents(students, string.Empty, string.Empty, counts, threshold).Any())
return false;
+ _profileService.ClearCurrentStudentHistory();
_temporaryRecordService.ResetStudentList(SelectedStudentListName);
return true;
}