diff --git a/SecRandom.Core.Tests/PluginDrawServiceTests.cs b/SecRandom.Core.Tests/PluginDrawServiceTests.cs index 4445b8c6..5a7d8040 100644 --- a/SecRandom.Core.Tests/PluginDrawServiceTests.cs +++ b/SecRandom.Core.Tests/PluginDrawServiceTests.cs @@ -179,13 +179,13 @@ public async Task AuthorizeAsync(IReadOnlyCollection op return true; } - public Task AuthorizePasswordAsync(TopLevel xamlRoot, Func action, CancellationToken cancellationToken = default) + public Task AuthorizePasswordAsync(Func action, CancellationToken cancellationToken = default) => AuthorizeAsync(SecurityOperation.OpenSettings, action, cancellationToken); public Task AuthorizeSettingsAsync(Func action, Func previewAction, CancellationToken cancellationToken = default) => Task.FromResult(new SecurityAuthorizationResult(allow, false)); - public Task UpdateSecuritySettingsAsync(TopLevel xamlRoot, Action update, CancellationToken cancellationToken = default) + public Task UpdateSecuritySettingsAsync(Action update, CancellationToken cancellationToken = default) { if (allow) update(); @@ -201,9 +201,6 @@ public Task RemovePasswordAsync(string currentPassword, CancellationToken public Task BeginTotpSetupAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); - public Task BeginTotpSetupAsync(TopLevel xamlRoot, CancellationToken cancellationToken = default) - => Task.FromResult(null); - public Task CancelTotpSetupAsync(string secret, CancellationToken cancellationToken = default) => Task.CompletedTask; @@ -219,15 +216,9 @@ public Task> GetUsbDevicesAsync(CancellationToken c public Task BindUsbAsync(string deviceId, CancellationToken cancellationToken = default) => Task.FromResult(allow); - public Task BindUsbAsync(TopLevel xamlRoot, string deviceId, CancellationToken cancellationToken = default) - => Task.FromResult(allow); - public Task UnbindUsbAsync(string bindingId, CancellationToken cancellationToken = default) => Task.FromResult(allow); - public Task UnbindUsbAsync(TopLevel xamlRoot, string bindingId, CancellationToken cancellationToken = default) - => Task.FromResult(allow); - public bool TryUpdateSettings(Action update) { if (allow) diff --git a/SecRandom.Core.Tests/SecurityServiceTests.cs b/SecRandom.Core.Tests/SecurityServiceTests.cs index e2cfe66d..ac839447 100644 --- a/SecRandom.Core.Tests/SecurityServiceTests.cs +++ b/SecRandom.Core.Tests/SecurityServiceTests.cs @@ -110,7 +110,6 @@ public async Task UpdateSecuritySettingsAsync_WhenPasswordIsAccepted_EnablesProt fixture.ConfigService.ResetSaveCount(); var updated = await fixture.Service.UpdateSecuritySettingsAsync( - null!, () => fixture.ConfigHandler.Data.SecuritySettings.SecurityEnabled = true, TestContext.Current.CancellationToken); @@ -128,7 +127,6 @@ public async Task UpdateSecuritySettingsAsync_WhenPasswordIsRejected_DoesNotChan fixture.ConfigService.ResetSaveCount(); var updated = await fixture.Service.UpdateSecuritySettingsAsync( - null!, () => fixture.ConfigHandler.Data.SecuritySettings.SecurityEnabled = true, TestContext.Current.CancellationToken); @@ -180,7 +178,7 @@ public async Task BeginTotpSetupAsync_WhenPasswordIsRejected_DoesNotRevealANewSe var fixture = CreateFixture(Password("wrong")); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - var secret = await fixture.Service.BeginTotpSetupAsync(null!, TestContext.Current.CancellationToken); + var secret = await fixture.Service.BeginTotpSetupAsync(TestContext.Current.CancellationToken); Assert.Null(secret); Assert.Equal([SecurityFactor.Password], Assert.Single(fixture.Prompt.Requests).RequiredFactors); @@ -210,7 +208,7 @@ public async Task RemovePasswordAsync_WhenUsbIsBound_DeletesTheBindingKey() Password("secret1"), new UsbDriveInfo("H:", "Remove password USB", "volume:remove-password", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(await fixture.Service.BindUsbAsync(null!, "volume:remove-password", TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync("volume:remove-password", TestContext.Current.CancellationToken)); Assert.True(File.Exists(Path.Combine(usbRoot, ".SecRandom.safety.key"))); var removed = await fixture.Service.RemovePasswordAsync("secret1", TestContext.Current.CancellationToken); @@ -224,7 +222,7 @@ public async Task CancelTotpSetupAsync_WhenConfirmationIsCancelled_CannotActivat { var fixture = CreateFixture(Password("secret1")); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - var secret = await fixture.Service.BeginTotpSetupAsync(null!, TestContext.Current.CancellationToken); + var secret = await fixture.Service.BeginTotpSetupAsync(TestContext.Current.CancellationToken); Assert.NotNull(secret); await fixture.Service.CancelTotpSetupAsync(secret, TestContext.Current.CancellationToken); @@ -240,7 +238,7 @@ public async Task ConfirmTotpAsync_WhenCredentialSaveFails_ReturnsFalseAndKeepsT var writeFault = new ThrowOnNthCredentialWrite(); var fixture = CreateFixture(Password("secret1"), writeFault); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - var secret = await fixture.Service.BeginTotpSetupAsync(null!, TestContext.Current.CancellationToken); + var secret = await fixture.Service.BeginTotpSetupAsync(TestContext.Current.CancellationToken); Assert.NotNull(secret); writeFault.ThrowOnWrite = writeFault.WriteCalls + 1; @@ -273,7 +271,7 @@ public async Task VerifyAsync_WhenAnySelectedFactorModeHasBoundUsb_AuthorizesWit Password("secret1"), new UsbDriveInfo("P:", "Authorization USB", "volume:usb-any", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(await fixture.Service.BindUsbAsync(null!, "volume:usb-any", TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync("volume:usb-any", TestContext.Current.CancellationToken)); fixture.ConfigHandler.Data.SecuritySettings.SecurityEnabled = true; fixture.ConfigHandler.Data.SecuritySettings.UsbBindingEnabled = true; fixture.ConfigHandler.Data.SecuritySettings.RequireAllSelectedFactors = false; @@ -293,7 +291,7 @@ public async Task VerifyAsync_WhenAllSelectedFactorModeHasOnlyUsb_RejectsAuthori Password("secret1"), new UsbDriveInfo("Q:", "Authorization USB", "volume:usb-all", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(await fixture.Service.BindUsbAsync(null!, "volume:usb-all", TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync("volume:usb-all", TestContext.Current.CancellationToken)); fixture.ConfigHandler.Data.SecuritySettings.SecurityEnabled = true; fixture.ConfigHandler.Data.SecuritySettings.UsbBindingEnabled = true; fixture.ConfigHandler.Data.SecuritySettings.RequireAllSelectedFactors = true; @@ -336,7 +334,7 @@ public async Task GetUsbDevicesAsync_ProjectsBoundAndUnboundRemovableDevices() new UsbDriveInfo("F:", "Backup USB", "volume:F", secondRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - var bound = await fixture.Service.BindUsbAsync(null!, "volume:E", TestContext.Current.CancellationToken); + var bound = await fixture.Service.BindUsbAsync("volume:E", TestContext.Current.CancellationToken); var devices = await fixture.Service.GetUsbDevicesAsync(TestContext.Current.CancellationToken); Assert.True(bound); @@ -356,7 +354,7 @@ public async Task GetUsbDevicesAsync_WhenBoundVolumeMoves_RecognizesItByDeviceId Password("secret1"), new UsbDriveInfo("E:", "Portable USB", "volume:4A2B", originalRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(await fixture.Service.BindUsbAsync(null!, "volume:4A2B", TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync("volume:4A2B", TestContext.Current.CancellationToken)); Directory.Move(originalRoot, movedRoot); fixture.UsbCatalog.SetDevices(new UsbDriveInfo("F:", "Portable USB", "volume:4A2B", movedRoot)); @@ -376,7 +374,7 @@ public async Task GetUsbDevicesAsync_WhenMarkerIsMissing_DoesNotProjectTheVolume Password("secret1"), new UsbDriveInfo("E:", "Marker USB", "volume:missing-marker", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(await fixture.Service.BindUsbAsync(null!, "volume:missing-marker", TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync("volume:missing-marker", TestContext.Current.CancellationToken)); File.Delete(Path.Combine(usbRoot, ".SecRandom.safety.key")); @@ -384,7 +382,7 @@ public async Task GetUsbDevicesAsync_WhenMarkerIsMissing_DoesNotProjectTheVolume Assert.False(device.IsBound); Assert.True(device.IsPresent); - Assert.True(await fixture.Service.BindUsbAsync(null!, "volume:missing-marker", TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync("volume:missing-marker", TestContext.Current.CancellationToken)); Assert.Single(await fixture.Service.GetUsbBindingsAsync(TestContext.Current.CancellationToken)); } @@ -397,7 +395,7 @@ public async Task BindUsbAsync_WhenPasswordIsRejected_DoesNotWriteABindingKey() new UsbDriveInfo("G:", "Rejected USB", "volume:G", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - var bound = await fixture.Service.BindUsbAsync(null!, "volume:G", TestContext.Current.CancellationToken); + var bound = await fixture.Service.BindUsbAsync("volume:G", TestContext.Current.CancellationToken); Assert.False(bound); Assert.False(File.Exists(Path.Combine(usbRoot, ".SecRandom.safety.key"))); @@ -413,7 +411,7 @@ public async Task BindUsbAsync_WhenCallerSuppliesAPathInsteadOfADeviceId_Rejects new UsbDriveInfo("I:", "Path input USB", "volume:path-input", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - var bound = await fixture.Service.BindUsbAsync(null!, usbRoot, TestContext.Current.CancellationToken); + var bound = await fixture.Service.BindUsbAsync(usbRoot, TestContext.Current.CancellationToken); Assert.False(bound); Assert.False(File.Exists(Path.Combine(usbRoot, ".SecRandom.safety.key"))); @@ -431,7 +429,7 @@ public async Task BindUsbAsync_WhenCredentialSaveFails_RemovesTheWrittenUsbKey() await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); writeFault.ThrowOnWrite = writeFault.WriteCalls + 2; - var bound = await fixture.Service.BindUsbAsync(null!, "volume:save-failure", TestContext.Current.CancellationToken); + var bound = await fixture.Service.BindUsbAsync("volume:save-failure", TestContext.Current.CancellationToken); Assert.False(bound); Assert.False(File.Exists(Path.Combine(usbRoot, ".SecRandom.safety.key"))); @@ -449,7 +447,7 @@ public async Task BindUsbAsync_WhenUsbAlreadyContainsASafetyKey_RejectsAndPreser new UsbDriveInfo("M:", "Existing key USB", "volume:existing-key", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - var bound = await fixture.Service.BindUsbAsync(null!, "volume:existing-key", TestContext.Current.CancellationToken); + var bound = await fixture.Service.BindUsbAsync("volume:existing-key", TestContext.Current.CancellationToken); Assert.False(bound); Assert.Equal("existing-token", File.ReadAllText(existingKeyPath, System.Text.Encoding.ASCII)); @@ -466,11 +464,11 @@ public async Task UnbindUsbAsync_WhenCredentialSaveFails_KeepsTheExistingUsbKey( writeFault, new UsbDriveInfo("K:", "Unbind persistence USB", "volume:unbind-save-failure", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(await fixture.Service.BindUsbAsync(null!, "volume:unbind-save-failure", TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync("volume:unbind-save-failure", TestContext.Current.CancellationToken)); var binding = Assert.Single(await fixture.Service.GetUsbBindingsAsync(TestContext.Current.CancellationToken)); writeFault.ThrowOnWrite = writeFault.WriteCalls + 2; - var unbound = await fixture.Service.UnbindUsbAsync(null!, binding.Id, TestContext.Current.CancellationToken); + var unbound = await fixture.Service.UnbindUsbAsync(binding.Id, TestContext.Current.CancellationToken); Assert.False(unbound); Assert.True(File.Exists(Path.Combine(usbRoot, ".SecRandom.safety.key"))); @@ -484,17 +482,17 @@ public async Task BindUsbAsync_AfterUnbindWhileVolumeIsMissing_ReplacesItsPendin var drive = new UsbDriveInfo("N:", "Pending marker USB", "volume:pending-marker", usbRoot); var fixture = CreateFixture(Password("secret1"), drive); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(await fixture.Service.BindUsbAsync(null!, drive.DeviceId, TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync(drive.DeviceId, TestContext.Current.CancellationToken)); var markerPath = Path.Combine(usbRoot, ".SecRandom.safety.key"); var originalToken = File.ReadAllText(markerPath, System.Text.Encoding.ASCII); var binding = Assert.Single(await fixture.Service.GetUsbBindingsAsync(TestContext.Current.CancellationToken)); fixture.UsbCatalog.SetDevices(); - Assert.True(await fixture.Service.UnbindUsbAsync(null!, binding.Id, TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.UnbindUsbAsync(binding.Id, TestContext.Current.CancellationToken)); fixture.UsbCatalog.SetDevices(drive); - Assert.True(await fixture.Service.BindUsbAsync(null!, drive.DeviceId, TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync(drive.DeviceId, TestContext.Current.CancellationToken)); Assert.NotEqual(originalToken, File.ReadAllText(markerPath, System.Text.Encoding.ASCII)); Assert.Single(await fixture.Service.GetUsbBindingsAsync(TestContext.Current.CancellationToken)); } @@ -509,7 +507,7 @@ public async Task RemovePasswordAsync_WhenCredentialSaveFails_KeepsTheBoundUsbKe writeFault, new UsbDriveInfo("L:", "Password removal USB", "volume:remove-password-save-failure", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(await fixture.Service.BindUsbAsync(null!, "volume:remove-password-save-failure", TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync("volume:remove-password-save-failure", TestContext.Current.CancellationToken)); writeFault.ThrowOnWrite = writeFault.WriteCalls + 1; var removed = await fixture.Service.RemovePasswordAsync("secret1", TestContext.Current.CancellationToken); @@ -529,7 +527,7 @@ public async Task RemovePasswordAsync_WhenTheBoundKeyChanged_StillDeletesTheExte Password("secret1"), new UsbDriveInfo("O:", "Replaced key USB", "volume:remove-password-replaced-key", usbRoot)); await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); - Assert.True(await fixture.Service.BindUsbAsync(null!, "volume:remove-password-replaced-key", TestContext.Current.CancellationToken)); + Assert.True(await fixture.Service.BindUsbAsync("volume:remove-password-replaced-key", TestContext.Current.CancellationToken)); File.WriteAllText(keyPath, "replacement-token", System.Text.Encoding.ASCII); var removed = await fixture.Service.RemovePasswordAsync("secret1", TestContext.Current.CancellationToken); @@ -599,13 +597,13 @@ private sealed class ScriptedPrompt(SecurityVerificationResponse response) : ISe { public List Requests { get; } = []; - public Task RequestAsync( - TopLevel xamlRoot, + public Task RequestAsync( SecurityVerificationRequest request, + Func> verify, CancellationToken cancellationToken = default) { Requests.Add(request); - return Task.FromResult(response); + return verify(response, cancellationToken); } } diff --git a/SecRandom.Core.Tests/SettingsMarkupTests.cs b/SecRandom.Core.Tests/SettingsMarkupTests.cs index 9005d41e..c2469ed7 100644 --- a/SecRandom.Core.Tests/SettingsMarkupTests.cs +++ b/SecRandom.Core.Tests/SettingsMarkupTests.cs @@ -402,7 +402,7 @@ public void SecuritySettingsUseVerifiedEnablementAndSeparatePasswordCommands() Assert.Contains("Click=\"ChangePassword_OnClick\"", markup, StringComparison.Ordinal); Assert.Contains("Click=\"RemovePassword_OnClick\"", markup, StringComparison.Ordinal); Assert.Contains("UpdateSecuritySettingsAsync", source, StringComparison.Ordinal); - Assert.Contains("BeginTotpSetupAsync(xamlRoot", source, StringComparison.Ordinal); + Assert.Contains("BeginTotpSetupAsync(", source, StringComparison.Ordinal); Assert.Contains("GetUsbDevicesAsync", source, StringComparison.Ordinal); } diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 9d3aba70..a9e8ff87 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -128,27 +128,6 @@ public partial class App : Application internal bool IsStopping => _isStopping; public static bool IsDesktop; - public TopLevel GetRootWindow() - { - if (_desktopLifetime?.Windows - .Where(window => window.GetType().Name != "TrayPopupRoot" - && window is { IsActive: true, IsVisible: true, PlatformImpl: not null }) - .OrderBy(window => ReferenceEquals(window, _floatingWindow) ? 1 : 0) - .FirstOrDefault() is TopLevel desktopRoot) - return desktopRoot; - - if (_mobileViewHost is not null && TopLevel.GetTopLevel(_mobileViewHost) is { } mobileRoot) - return mobileRoot; - - if (_floatingWindow is { PlatformImpl: not null } floatingRoot) - { - floatingRoot.Activate(); - return floatingRoot; - } - - throw new InvalidOperationException("No active application TopLevel is available."); - } - public event EventHandler? AppStarted; public event EventHandler? AppStopping; @@ -1722,7 +1701,13 @@ public static void SetFloatingWindowVisibility(string action) public static void ShowSettingsWindow(string? pageId) { - ObserveTask(IAppHost.GetService().AuthorizeSettingsAsync( + ObserveTask(ShowSettingsWindowWithAuthAsync(pageId), + "Settings window authorization failed."); + } + + private static async Task ShowSettingsWindowWithAuthAsync(string? pageId) + { + await IAppHost.GetService().AuthorizeSettingsAsync( async () => { await ShowSettingsWindowCoreAsync(); @@ -1738,7 +1723,7 @@ public static void ShowSettingsWindow(string? pageId) "Settings preview display failed."); }, DispatcherPriority.Background); return Task.CompletedTask; - }), "Settings window authorization failed."); + }); } private static async Task ShowSettingsPreviewAsync(string? pageId) diff --git a/SecRandom/Langs/SettingsPages/Debug/Resources.en-US.resx b/SecRandom/Langs/SettingsPages/Debug/Resources.en-US.resx index e09c7fd2..eab2c0f2 100644 --- a/SecRandom/Langs/SettingsPages/Debug/Resources.en-US.resx +++ b/SecRandom/Langs/SettingsPages/Debug/Resources.en-US.resx @@ -268,9 +268,6 @@ Insider settings have lower priority than fair-draw rules. Selecting a member or ClassIsland has not loaded today's timetable. - - ClassIsland has not confirmed the current class-time state. - ClassIsland returned an unsupported current state: {0}. diff --git a/SecRandom/Langs/SettingsPages/Debug/Resources.ja-JP.resx b/SecRandom/Langs/SettingsPages/Debug/Resources.ja-JP.resx index 749e9bbb..4422c438 100644 --- a/SecRandom/Langs/SettingsPages/Debug/Resources.ja-JP.resx +++ b/SecRandom/Langs/SettingsPages/Debug/Resources.ja-JP.resx @@ -268,9 +268,6 @@ ClassIsland が当日の時間割を読み込んでいません。 - - ClassIsland が現在の授業時間状態を確認していません。 - ClassIsland から未対応の現在状態が返されました: {0}。 diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx b/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx index dad03b90..2b198b46 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx @@ -135,7 +135,6 @@ The ClassIsland timer is not running. ClassIsland has not enabled its timetable. ClassIsland has not loaded today's timetable. - ClassIsland has not confirmed the current class-time state. ClassIsland returned an unsupported current state: {0}. Unable to read the ClassIsland class state. diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx b/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx index 3947d101..ddafd484 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx @@ -135,7 +135,6 @@ ClassIsland のタイマーが動作していません。 ClassIsland で時間割が有効になっていません。 ClassIsland が当日の時間割を読み込んでいません。 - ClassIsland が現在の授業時間状態を確認していません。 ClassIsland から未対応の現在状態が返されました: {0}。 ClassIsland の授業状態を読み取れません。 diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.resx b/SecRandom/Langs/SettingsPages/Linkage/Resources.resx index 32334993..61964edd 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.resx +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.resx @@ -150,7 +150,6 @@ ClassIsland 计时器未运行 ClassIsland 未启用课程表 ClassIsland 尚未加载当天课程表 - ClassIsland 尚未确认当前课程时间状态 ClassIsland 返回了未支持的当前状态:{0} 无法读取 ClassIsland 的课程状态 diff --git a/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs b/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs index 8643ba50..414f1596 100644 --- a/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs +++ b/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using ClassIsland.Shared.Enums; @@ -21,6 +21,7 @@ public sealed class ClassIslandScheduleSource(ILogger private IPublicLessonsService? _lessons; private string _lastKnownCourseName = string.Empty; private DateOnly? _lastKnownCourseDate; + private DateTime? _lastKnownCourseEnd; private DateTimeOffset _nextConnectAttempt = DateTimeOffset.MinValue; public string SourceName => "ClassIsland"; @@ -40,13 +41,14 @@ public async Task GetSnapshotAsync(CancellationToken can return CourseScheduleSnapshot.Unavailable(SourceName, ScheduleErrorCodes.ClassIslandScheduleDisabled); if (!lessons.IsClassPlanLoaded) return CourseScheduleSnapshot.Unavailable(SourceName, ScheduleErrorCodes.ClassIslandScheduleUnloaded); - if (!lessons.IsLessonConfirmed) - return CourseScheduleSnapshot.Unavailable(SourceName, ScheduleErrorCodes.ClassIslandTimeUnconfirmed); var state = lessons.CurrentState switch { TimeState.OnClass => CourseTimeState.OnClass, - TimeState.Breaking => CourseTimeState.Breaking, + // ClassIsland 在最后一节课后报告 AfterSchool,在第一节课前或时间表未覆盖的间隙报告 None, + // PrepareOnClass 为预留的上课准备状态。这些都是明确的非上课时段,与 CSES 源一致视为 + // 课间并保持可用,由启用窗口决定是否豁免。 + TimeState.Breaking or TimeState.None or TimeState.AfterSchool or TimeState.PrepareOnClass => CourseTimeState.Breaking, _ => CourseTimeState.Unknown }; if (state == CourseTimeState.Unknown) @@ -68,6 +70,9 @@ public async Task GetSnapshotAsync(CancellationToken can var currentItem = lessons.CurrentTimeLayoutItem; var start = ParseTime(currentItem?.StartTime, now.TimeOfDay); var end = ParseTime(currentItem?.EndTime, now.TimeOfDay); + // 记录当前课程结束时间,供课后禁用延迟窗口计算使用 + if (state == CourseTimeState.OnClass && currentItem?.EndTime is { } endTime) + _lastKnownCourseEnd = now.Date + endTime; var current = string.IsNullOrEmpty(currentName) ? null : new CourseInfo(currentName, DayOfWeekNumber(now.DayOfWeek), TimeOnly.FromTimeSpan(start), TimeOnly.FromTimeSpan(end)); @@ -85,6 +90,12 @@ public async Task GetSnapshotAsync(CancellationToken can var currentCourseRemaining = state == CourseTimeState.OnClass ? Positive(lessons.OnBreakingTimeLeftTime) : null; + // 与 CSES 源一致:课后经过的时间驱动课后禁用延迟窗口与刷新调度 + var sincePreviousEnd = _lastKnownCourseEnd is { } lastEnd && + lastEnd.Date == now.Date && + lastEnd.TimeOfDay <= now.TimeOfDay + ? (TimeSpan?)(now - lastEnd) + : null; return new CourseScheduleSnapshot( true, state, @@ -93,7 +104,7 @@ public async Task GetSnapshotAsync(CancellationToken can next, currentCourseRemaining, nextCourseIn, - null, + sincePreviousEnd, SourceName, $"{lessons.CurrentSelectedIndex}:{lessons.CurrentState}"); } diff --git a/SecRandom/Services/Linkage/CsesScheduleSource.cs b/SecRandom/Services/Linkage/CsesScheduleSource.cs index d0156555..fe6dc78c 100644 --- a/SecRandom/Services/Linkage/CsesScheduleSource.cs +++ b/SecRandom/Services/Linkage/CsesScheduleSource.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using SecRandom.Core.Models.Linkage; @@ -34,7 +34,6 @@ internal static class ScheduleErrorCodes public const string ClassIslandTimerStopped = "classisland.timer-stopped"; public const string ClassIslandScheduleDisabled = "classisland.schedule-disabled"; public const string ClassIslandScheduleUnloaded = "classisland.schedule-unloaded"; - public const string ClassIslandTimeUnconfirmed = "classisland.time-unconfirmed"; public const string ClassIslandUnsupportedState = "classisland.unsupported-state"; public const string ClassIslandReadFailed = "classisland.read-failed"; } diff --git a/SecRandom/Services/Security/SecurityContracts.cs b/SecRandom/Services/Security/SecurityContracts.cs index 119520ab..2efaaa2a 100644 --- a/SecRandom/Services/Security/SecurityContracts.cs +++ b/SecRandom/Services/Security/SecurityContracts.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Avalonia.Controls; using SecRandom.Core.Enums.Configs; namespace SecRandom.Services.Security; @@ -62,7 +61,6 @@ public enum SecurityFactor public interface ISecurityVerificationPrompt { Task RequestAsync( - TopLevel xamlRoot, SecurityVerificationRequest request, Func> verify, CancellationToken cancellationToken = default); @@ -75,24 +73,21 @@ public interface ISecurityService Task VerifyAsync(SecurityVerificationResponse response, CancellationToken cancellationToken = default); Task AuthorizeAsync(SecurityOperation operation, Func action, CancellationToken cancellationToken = default); Task AuthorizeAsync(IReadOnlyCollection operations, Func action, CancellationToken cancellationToken = default); - Task AuthorizePasswordAsync(TopLevel xamlRoot, Func action, CancellationToken cancellationToken = default); + Task AuthorizePasswordAsync(Func action, CancellationToken cancellationToken = default); Task AuthorizeSettingsAsync( Func action, Func previewAction, CancellationToken cancellationToken = default); - Task UpdateSecuritySettingsAsync(TopLevel xamlRoot, Action update, CancellationToken cancellationToken = default); + Task UpdateSecuritySettingsAsync(Action update, CancellationToken cancellationToken = default); Task SetPasswordAsync(string password, string? currentPassword = null, CancellationToken cancellationToken = default); Task RemovePasswordAsync(string currentPassword, CancellationToken cancellationToken = default); Task BeginTotpSetupAsync(CancellationToken cancellationToken = default); - Task BeginTotpSetupAsync(TopLevel xamlRoot, CancellationToken cancellationToken = default); Task CancelTotpSetupAsync(string secret, CancellationToken cancellationToken = default); Task ConfirmTotpAsync(string secret, string code, CancellationToken cancellationToken = default); Task> GetUsbBindingsAsync(CancellationToken cancellationToken = default); Task> GetUsbDevicesAsync(CancellationToken cancellationToken = default); Task BindUsbAsync(string deviceId, CancellationToken cancellationToken = default); - Task BindUsbAsync(TopLevel xamlRoot, string deviceId, CancellationToken cancellationToken = default); Task UnbindUsbAsync(string bindingId, CancellationToken cancellationToken = default); - Task UnbindUsbAsync(TopLevel xamlRoot, string bindingId, CancellationToken cancellationToken = default); bool TryUpdateSettings(Action update); } diff --git a/SecRandom/Services/Security/SecurityDialogHost.cs b/SecRandom/Services/Security/SecurityDialogHost.cs new file mode 100644 index 00000000..0a7e63d0 --- /dev/null +++ b/SecRandom/Services/Security/SecurityDialogHost.cs @@ -0,0 +1,24 @@ +using Avalonia.Controls; +using SecRandom.Core.Abstraction; +using SecRandom.Platforms.Abstractions; + +namespace SecRandom.Services.Security; + +internal sealed class SecurityDialogHost : Window +{ + public SecurityDialogHost() + { + ShowInTaskbar = false; + WindowDecorations = WindowDecorations.None; + ExtendClientAreaToDecorationsHint = true; + CanResize = false; + Width = 1; + Height = 1; + Opacity = 0; + IsVisible = false; + var featureService = IAppHost.TryGetService(); + featureService?.Apply( + new PlatformWindowHandle(nint.Zero, null), + new WindowFeatureRequest(WindowFeatures.ToolWindow, true)); + } +} diff --git a/SecRandom/Services/Security/SecurityService.cs b/SecRandom/Services/Security/SecurityService.cs index 19dfae2d..868a820b 100644 --- a/SecRandom/Services/Security/SecurityService.cs +++ b/SecRandom/Services/Security/SecurityService.cs @@ -6,7 +6,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Avalonia.Controls; using Microsoft.Extensions.Logging; using SecRandom.Core.Enums.Configs; using SecRandom.Core.Models.SubConfigs; @@ -121,7 +120,7 @@ public async Task AuthorizeAsync( GetRequiredFactors(metadata), Settings.RequireAllSelectedFactors, GetLockoutRemaining(metadata.LockedUntilUtc)); - var result = await prompt.RequestAsync(App.Current.GetRootWindow(), request, VerifyAsync, cancellationToken); + var result = await prompt.RequestAsync(request, VerifyAsync, cancellationToken); if (!result.IsAuthorized) { logger.LogInformation("Security authorization rejected for {Operations}: {Failure}", string.Join(',', operations), result.Failure); @@ -143,12 +142,10 @@ private bool RequiresVerification(IReadOnlyCollection operati } public Task AuthorizePasswordAsync( - TopLevel xamlRoot, Func action, CancellationToken cancellationToken = default) { return AuthorizePasswordCoreAsync( - xamlRoot, async _ => { await action(); @@ -183,7 +180,7 @@ public async Task AuthorizeSettingsAsync( Settings.RequireAllSelectedFactors, GetLockoutRemaining(metadata.LockedUntilUtc), Settings.AllowSettingsPreview); - var result = await prompt.RequestAsync(App.Current.GetRootWindow(), request, VerifyAsync, cancellationToken); + var result = await prompt.RequestAsync(request, VerifyAsync, cancellationToken); if (result.Failure == SecurityVerificationFailure.PreviewRequested && request.AllowPreview) { await previewAction(); @@ -205,11 +202,10 @@ public async Task AuthorizeSettingsAsync( } public Task UpdateSecuritySettingsAsync( - TopLevel xamlRoot, Action update, CancellationToken cancellationToken = default) { - return AuthorizePasswordCoreAsync(xamlRoot, context => + return AuthorizePasswordCoreAsync(context => { lock (_gate) { @@ -291,7 +287,6 @@ public Task VerifyAsync( } private async Task AuthorizePasswordCoreAsync( - TopLevel xamlRoot, Func> action, CancellationToken cancellationToken) { @@ -352,7 +347,7 @@ Task VerifyPasswordAsync( } } - var result = await prompt.RequestAsync(xamlRoot, request, VerifyPasswordAsync, cancellationToken); + var result = await prompt.RequestAsync(request, VerifyPasswordAsync, cancellationToken); if (!result.IsAuthorized || context is null) return false; @@ -466,12 +461,7 @@ public Task RemovePasswordAsync(string currentPassword, CancellationToken } } - public Task BeginTotpSetupAsync(CancellationToken cancellationToken = default) - { - return BeginTotpSetupAsync(App.Current.GetRootWindow(), cancellationToken); - } - - public async Task BeginTotpSetupAsync(TopLevel xamlRoot, CancellationToken cancellationToken = default) + public async Task BeginTotpSetupAsync(CancellationToken cancellationToken = default) { lock (_gate) { @@ -481,7 +471,7 @@ public Task RemovePasswordAsync(string currentPassword, CancellationToken } string? secret = null; - var authorized = await AuthorizePasswordCoreAsync(xamlRoot, context => + var authorized = await AuthorizePasswordCoreAsync(context => { lock (_gate) { @@ -584,15 +574,10 @@ public Task> GetUsbDevicesAsync(CancellationToken c } } - public Task BindUsbAsync(string deviceId, CancellationToken cancellationToken = default) - { - return BindUsbAsync(App.Current.GetRootWindow(), deviceId, cancellationToken); - } - - public async Task BindUsbAsync(TopLevel xamlRoot, string deviceId, CancellationToken cancellationToken = default) + public async Task BindUsbAsync(string deviceId, CancellationToken cancellationToken = default) { var bound = false; - var authorized = await AuthorizePasswordCoreAsync(xamlRoot, context => + var authorized = await AuthorizePasswordCoreAsync(context => { lock (_gate) bound = BindUsbCore(context, deviceId); @@ -651,15 +636,10 @@ private bool BindUsbCore(SecurityCredentialContext context, string deviceId) } } - public Task UnbindUsbAsync(string bindingId, CancellationToken cancellationToken = default) - { - return UnbindUsbAsync(App.Current.GetRootWindow(), bindingId, cancellationToken); - } - - public async Task UnbindUsbAsync(TopLevel xamlRoot, string bindingId, CancellationToken cancellationToken = default) + public async Task UnbindUsbAsync(string bindingId, CancellationToken cancellationToken = default) { var unbound = false; - var authorized = await AuthorizePasswordCoreAsync(xamlRoot, context => + var authorized = await AuthorizePasswordCoreAsync(context => { lock (_gate) unbound = UnbindUsbCore(context, bindingId); diff --git a/SecRandom/Services/Security/SecurityVerificationPrompt.cs b/SecRandom/Services/Security/SecurityVerificationPrompt.cs index 5e096bd3..8bb5fe27 100644 --- a/SecRandom/Services/Security/SecurityVerificationPrompt.cs +++ b/SecRandom/Services/Security/SecurityVerificationPrompt.cs @@ -1,6 +1,5 @@ using System.Threading; using System.Threading.Tasks; -using Avalonia.Controls; namespace SecRandom.Services.Security; @@ -9,7 +8,6 @@ public sealed class SecurityVerificationPrompt : ISecurityVerificationPrompt private bool _isShowing; public async Task RequestAsync( - TopLevel xamlRoot, SecurityVerificationRequest request, Func> verify, CancellationToken cancellationToken = default) @@ -20,7 +18,7 @@ public async Task RequestAsync( _isShowing = true; try { - return await SecurityVerificationDialog.ShowAsync(xamlRoot, request, verify, cancellationToken) + return await SecurityVerificationDialog.ShowAsync(request, verify, cancellationToken) .WaitAsync(cancellationToken); } catch (OperationCanceledException) diff --git a/SecRandom/Services/Security/SecurityVerificationWindow.cs b/SecRandom/Services/Security/SecurityVerificationWindow.cs index 81caf0ef..37f0f160 100644 --- a/SecRandom/Services/Security/SecurityVerificationWindow.cs +++ b/SecRandom/Services/Security/SecurityVerificationWindow.cs @@ -20,7 +20,6 @@ namespace SecRandom.Services.Security; internal static class SecurityVerificationDialog { public static async Task ShowAsync( - TopLevel xamlRoot, SecurityVerificationRequest request, Func> verify, CancellationToken cancellationToken = default) @@ -136,9 +135,12 @@ async Task RefreshUsbStatusAsync() panel.Children.Add(usbStatusPanel); } + var host = new SecurityDialogHost(); + host.Show(); + var dialog = new FATaskDialog { - XamlRoot = xamlRoot, + XamlRoot = host, Title = SR.M_VerificationDialogTitle, Header = SR.M_VerificationDialogTitle, Content = panel @@ -207,12 +209,19 @@ async Task RefreshUsbStatusAsync() timer.Start(); } - return await dialog.ShowAsync() switch + try { - "preview" => new SecurityVerificationResult(false, SecurityVerificationFailure.PreviewRequested), - "verify" => finalResult ?? new SecurityVerificationResult(false, SecurityVerificationFailure.Cancelled), - _ => new SecurityVerificationResult(false, SecurityVerificationFailure.Cancelled) - }; + return await dialog.ShowAsync() switch + { + "preview" => new SecurityVerificationResult(false, SecurityVerificationFailure.PreviewRequested), + "verify" => finalResult ?? new SecurityVerificationResult(false, SecurityVerificationFailure.Cancelled), + _ => new SecurityVerificationResult(false, SecurityVerificationFailure.Cancelled) + }; + } + finally + { + host.Close(); + } void ShowError(SecurityVerificationResult result) { diff --git a/SecRandom/Views/SettingsPages/DebugSettingsPage.axaml.cs b/SecRandom/Views/SettingsPages/DebugSettingsPage.axaml.cs index d94842d5..9aed0ccf 100644 --- a/SecRandom/Views/SettingsPages/DebugSettingsPage.axaml.cs +++ b/SecRandom/Views/SettingsPages/DebugSettingsPage.axaml.cs @@ -244,7 +244,6 @@ private void RefreshDiagnostics() ScheduleErrorCodes.ClassIslandTimerStopped => "M_ScheduleError_ClassIslandTimerStopped", ScheduleErrorCodes.ClassIslandScheduleDisabled => "M_ScheduleError_ClassIslandScheduleDisabled", ScheduleErrorCodes.ClassIslandScheduleUnloaded => "M_ScheduleError_ClassIslandScheduleUnloaded", - ScheduleErrorCodes.ClassIslandTimeUnconfirmed => "M_ScheduleError_ClassIslandTimeUnconfirmed", ScheduleErrorCodes.ClassIslandUnsupportedState => "M_ScheduleError_ClassIslandUnsupportedState", ScheduleErrorCodes.ClassIslandReadFailed => "M_ScheduleError_ClassIslandReadFailed", _ => null diff --git a/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml.cs b/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml.cs index 2a345c29..acd7bc39 100644 --- a/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml.cs +++ b/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml.cs @@ -99,14 +99,7 @@ private async void SelectedFactorOptionsOnCollectionChanged(object? sender, Noti return; } - if (TopLevel.GetTopLevel(this) is not { } xamlRoot) - { - RefreshSecurityState(); - return; - } - await ApplySecuritySettingsUpdateAsync( - xamlRoot, () => { foreach (var option in FactorOptions) @@ -169,14 +162,7 @@ private async void SecurityEnabled_OnIsCheckedChanged(object? sender, RoutedEven requested == Settings.SecurityEnabled) return; - if (TopLevel.GetTopLevel(this) is not { } xamlRoot) - { - RefreshSecurityState(); - return; - } - await ApplySecuritySettingsUpdateAsync( - xamlRoot, () => Settings.SecurityEnabled = requested, () => toggle.IsChecked = Settings.SecurityEnabled); } @@ -192,14 +178,7 @@ toggle.IsChecked is not { } requested || if (requested == current) return; - if (TopLevel.GetTopLevel(this) is not { } xamlRoot) - { - RefreshSecurityState(); - return; - } - await ApplySecuritySettingsUpdateAsync( - xamlRoot, () => setValue(requested), () => toggle.IsChecked = current); } @@ -246,8 +225,7 @@ private async Task SavePasswordAsync(PasswordEditorResult result) private async void ManageTotp_OnClick(object? sender, RoutedEventArgs e) { - if (TopLevel.GetTopLevel(this) is not { } xamlRoot) return; - var secret = await _securityService.BeginTotpSetupAsync(xamlRoot); + var secret = await _securityService.BeginTotpSetupAsync(); if (secret is null) { if (!_securityService.GetUiState().HasPassword) @@ -255,6 +233,7 @@ private async void ManageTotp_OnClick(object? sender, RoutedEventArgs e) return; } + if (TopLevel.GetTopLevel(this) is not { } xamlRoot) return; var code = await SecuritySetupDialogs.ShowTotpSetupAsync(xamlRoot, secret); if (code is not null && await _securityService.ConfirmTotpAsync(secret, code)) this.ShowSuccessToast(SR.M_TotpSaved); @@ -270,20 +249,20 @@ private async void ManageUsb_OnClick(object? sender, RoutedEventArgs e) await _securityService.GetUsbDevicesAsync()); if (result is null) return; var success = result.UnbindId is not null - ? await _securityService.UnbindUsbAsync(xamlRoot, result.UnbindId) - : await _securityService.BindUsbAsync(xamlRoot, result.DeviceId!); + ? await _securityService.UnbindUsbAsync(result.UnbindId) + : await _securityService.BindUsbAsync(result.DeviceId!); if (success) this.ShowSuccessToast(SR.M_UsbUpdated); else this.ShowErrorToast(SR.M_UsbUpdateFailed); RefreshSecurityState(); } - private async Task ApplySecuritySettingsUpdateAsync(TopLevel xamlRoot, Action update, Action restoreView) + private async Task ApplySecuritySettingsUpdateAsync(Action update, Action restoreView) { _refreshing = true; try { restoreView(); - await _securityService.UpdateSecuritySettingsAsync(xamlRoot, update); + await _securityService.UpdateSecuritySettingsAsync(update); } finally {