From 0091260f340476538a8428af424cc5acb4e50ed7 Mon Sep 17 00:00:00 2001 From: CreeperAWA Date: Sat, 19 Sep 2026 15:30:35 +0800 Subject: [PATCH 1/5] feat: add sudo mode support with configurable duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了sudo模式功能,验证通过后可在配置时长内跳过敏感操作验证。包含全局sudo模式和设置页面独立sudo模式,添加了退出sudo模式的托盘菜单项,支持自定义sudo有效时长。 --- AGENTS.md | 1 + .../SubConfigs/SecuritySettingsConfig.cs | 2 + SecRandom/App.axaml | 6 ++ SecRandom/App.axaml.cs | 23 ++++++ SecRandom/Langs/Common/Resources.Designer.cs | 6 ++ SecRandom/Langs/Common/Resources.en-US.resx | 3 + SecRandom/Langs/Common/Resources.ja-JP.resx | 1 + SecRandom/Langs/Common/Resources.resx | 3 + .../Security/Resources.Security.cs | 5 ++ .../Security/Resources.en-US.resx | 5 ++ .../Security/Resources.ja-JP.resx | 7 +- .../SettingsPages/Security/Resources.resx | 5 ++ .../Services/Security/SecurityContracts.cs | 6 ++ .../Services/Security/SecurityService.cs | 76 +++++++++++++++++++ .../General/SecuritySettingsPage.axaml | 17 +++++ .../General/SecuritySettingsPage.axaml.cs | 14 ++++ SecRandom/Views/SettingsView.axaml.cs | 1 + 17 files changed, 180 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1724ba4a7..c62a7b376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,7 @@ Keep this map short and stable. When code moves, AI agents should re-read the mo - Security credentials must never be stored in `MainConfigModel` or `settings.json`. Keep passwords, TOTP seeds, USB binding tokens, and lockout state in `SecRandom/Services/Security`'s separate credential store; ordinary settings only select factors and protected operations. The sole credential file is `data/config/security/credentials.json`: its internal `FormatVersion` is authoritative, uses Argon2id-derived key material plus AES-256-GCM for portable storage, and must not depend on DPAPI, Keychain, `secret-tool`, or a versioned filename. Do not read or migrate older credential files. - OmniTTS cloud speech API keys are credentials too: they live in `data/config/voice/omnitts-keys.json` via `OmniTtsCredentialStore` (app-layer, atomic replace), never in `VoiceSettingsConfig`/`settings.json`, IPC payloads, logs, telemetry, backups, or the Android DocumentsProvider. OmniTTS is voice engine 2 (`OmniTtsSpeechProvider.OmniEngine`), sits beside System SAPI (0) and Edge TTS (1), and is a unified cloud TTS engine whose providers are OpenAI, Gemini, FishAudio, MiMo, and Custom OpenAI-compatible. OpenAI, Gemini, and MiMo voice choices use documented preset sets; Gemini uses native `/v1beta/interactions` synthesis and wraps its 24 kHz PCM response as WAV. MiMo voice-design and voice-clone models use model-specific chat-completions payloads; clone references are WAV files kept privately under `data/config/voice/mimo-voice-reference.wav`, identified by a hash, and excluded from settings, archives, IPC, and logs. Models are fetched from provider APIs or typed manually. The voice settings page shows OmniTTS-specific rows (provider, base URL, key, model, voice, batch cache) only while `VoiceEngine == 2`. `data/config/voice/` is excluded from archives and protected from the Android provider/directory launcher. iOS keeps the voice settings page unregistered (`!OperatingSystem.IsIOS()`); OmniTTS targets Windows, Linux, macOS, and Android. - Security authorization always flows through `ISecurityService`; do not add direct validation checks to tray handlers, windows, ViewModels, or linkage code. Passwords require at least 6 characters, with no artificial character-class rule. +- Sudo mode is always enabled: after a successful authentication, protected operations skip re-verification for a configurable duration (`SudoModeDurationSeconds`, default 60s). The settings page has its own independent sudo lifetime: once verified while the settings window is open, all subsequent operations within that session are免验证 until the window closes. TOTP setup (`BeginTotpSetupAsync`) must always require fresh verification and must never be bypassed by sudo mode. The tray menu exposes an "Exit Sudo Mode" option (visible only when global sudo is active) that calls `DeactivateGlobalSudoMode()` to clear the global timer only; it cannot clear the settings-page sudo state. - Full IPC/URL compatibility is app-layer routed through `ProtocolCommandRouter`: structured current-user named-pipe IPC is additive to legacy `ShowMainWindow`/`Restart`/`Url:` delivery, and all external mutations must use `ISecurityService`. `data/*` queries must use non-mutating profile snapshots, never active-profile loading APIs. - `HistoryItem.DrawRoundId` identifies every record committed by one logical draw. Populate it for new history writes; IPC history projections group by it and must never expose internal `RecordId`. - Draw commits must go through `IDrawCommitService` (`DrawCommitCoordinator`): one logical draw gets exactly one `DrawRoundId` (a caller may supply one), temporary records commit before persistent history, a mid-commit failure rolls back through snapshot compensation, and commits serialize behind the coordinator gate. Never reintroduce bare two-step writes of `IProfileService.Record*History` plus temporary-record calls; the optional `drawRoundId` parameter (and `drawMethod` on `RecordPrizeHistory`) exists for coordinator use. diff --git a/SecRandom.Core/Models/SubConfigs/SecuritySettingsConfig.cs b/SecRandom.Core/Models/SubConfigs/SecuritySettingsConfig.cs index 6d204183b..dba673b14 100644 --- a/SecRandom.Core/Models/SubConfigs/SecuritySettingsConfig.cs +++ b/SecRandom.Core/Models/SubConfigs/SecuritySettingsConfig.cs @@ -24,6 +24,8 @@ public partial class SecuritySettingsConfig : ObservableObject [ObservableProperty] private bool _protectLotteryReset; [ObservableProperty] private bool _protectLinkage; + [ObservableProperty] private int _sudoModeDurationSeconds = 60; + // Compatibility bridges for the original placeholder fields. public bool VerifyBeforeSensitiveOperations { diff --git a/SecRandom/App.axaml b/SecRandom/App.axaml index 8197df390..1d4122a8c 100644 --- a/SecRandom/App.axaml +++ b/SecRandom/App.axaml @@ -50,6 +50,12 @@ + + + + diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 9d3aba70e..e47f3850e 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -114,6 +114,7 @@ public partial class App : Application private static MainWindow? _settingsWindow; private static Task? _runtimeServicesStartupTask; private NativeMenuItem? _floatingWindowMenuItem; + private NativeMenuItem? _exitSudoModeMenuItem; private static IClassicDesktopStyleApplicationLifetime? _desktopLifetime; private IHost? _mobileHost; private ISingleViewApplicationLifetime? _singleViewLifetime; @@ -1119,7 +1120,11 @@ public void InitializeApp() var menu = this.FindResource(@"AppMenu") as NativeMenu; taskBarIconService.MainTaskBarIcon.Menu = menu; _floatingWindowMenuItem = menu?.Items.ElementAtOrDefault(3) as NativeMenuItem; + _exitSudoModeMenuItem = menu?.Items.ElementAtOrDefault(9) as NativeMenuItem; RefreshTrayWindowMenuItems(); + RefreshSudoMenuItem(); + + IAppHost.GetService().SudoModeChanged += RefreshSudoMenuItem; taskBarIconService.MainTaskBarIcon.IsVisible = true; taskBarIconService.MainTaskBarIcon.Clicked += MainTaskBarIconOnClicked; IAppHost.GetService().EnsureConfiguredIntegrations(); @@ -1142,6 +1147,8 @@ public async Task StopAsync() private async Task StopAsync(bool requestLifetimeShutdown) { + IAppHost.TryGetService().SudoModeChanged -= RefreshSudoMenuItem; + lock (_shutdownGate) { if (_isStopping) @@ -2080,6 +2087,8 @@ private void MainTaskBarIconOnClicked(object? sender, EventArgs e) return; } + RefreshSudoMenuItem(); + var taskBarIconService = IAppHost.Host!.Services .GetServices().OfType().First(); @@ -2143,11 +2152,25 @@ private void RefreshTrayWindowMenuItems() : SecRandom.Langs.Common.Resources.Menu_ShowFloatingWindow; } + private void RefreshSudoMenuItem() + { + if (_exitSudoModeMenuItem is not null) + { + var securityService = IAppHost.GetService(); + _exitSudoModeMenuItem.IsVisible = securityService.IsGlobalSudoModeActive(); + } + } + private void MenuItemOpenSettings_OnClick(object? sender, EventArgs e) { ShowSettingsWindow(); } + private void MenuItemExitSudoMode_OnClick(object? sender, EventArgs e) + { + IAppHost.GetService().DeactivateGlobalSudoMode(); + } + private void MenuItemRestartProgram_OnClick(object? sender, EventArgs e) { ObserveTask(IAppHost.GetService().AuthorizeAsync( diff --git a/SecRandom/Langs/Common/Resources.Designer.cs b/SecRandom/Langs/Common/Resources.Designer.cs index 09b6711f6..c47fc0f3f 100644 --- a/SecRandom/Langs/Common/Resources.Designer.cs +++ b/SecRandom/Langs/Common/Resources.Designer.cs @@ -237,6 +237,12 @@ public static string Menu_ExitProgram { } } + public static string Menu_ExitSudoMode { + get { + return ResourceManager.GetString("Menu_ExitSudoMode", resourceCulture); + } + } + public static string App_Description { get { return ResourceManager.GetString("App_Description", resourceCulture); diff --git a/SecRandom/Langs/Common/Resources.en-US.resx b/SecRandom/Langs/Common/Resources.en-US.resx index 0f51a8269..77c4fd6d1 100644 --- a/SecRandom/Langs/Common/Resources.en-US.resx +++ b/SecRandom/Langs/Common/Resources.en-US.resx @@ -268,4 +268,7 @@ Move the entire portable package to a writable location, or adjust the directory Go + + Exit Sudo Mode + diff --git a/SecRandom/Langs/Common/Resources.ja-JP.resx b/SecRandom/Langs/Common/Resources.ja-JP.resx index 0e579f123..caa86207e 100644 --- a/SecRandom/Langs/Common/Resources.ja-JP.resx +++ b/SecRandom/Langs/Common/Resources.ja-JP.resx @@ -122,4 +122,5 @@ 抽 + Sudo モードを終了 diff --git a/SecRandom/Langs/Common/Resources.resx b/SecRandom/Langs/Common/Resources.resx index 5907f50f7..111e583b6 100644 --- a/SecRandom/Langs/Common/Resources.resx +++ b/SecRandom/Langs/Common/Resources.resx @@ -268,4 +268,7 @@ 抽 + + 退出 Sudo 模式 + diff --git a/SecRandom/Langs/SettingsPages/Security/Resources.Security.cs b/SecRandom/Langs/SettingsPages/Security/Resources.Security.cs index 174270e93..7b60359a5 100644 --- a/SecRandom/Langs/SettingsPages/Security/Resources.Security.cs +++ b/SecRandom/Langs/SettingsPages/Security/Resources.Security.cs @@ -98,4 +98,9 @@ public partial class Resources public static string M_UsbUpdated => Text(nameof(M_UsbUpdated)); public static string M_UsbUpdateFailed => Text(nameof(M_UsbUpdateFailed)); public static string M_Copied => Text(nameof(M_Copied)); + public static string S_SudoModeDuration => Text(nameof(S_SudoModeDuration)); + public static string S_SudoModeDuration_D => Text(nameof(S_SudoModeDuration_D)); + public static string S_SudoModeDurationUnit => Text(nameof(S_SudoModeDurationUnit)); + public static string Menu_ExitSudoMode => Text(nameof(Menu_ExitSudoMode)); + public static string M_SudoModeExited => Text(nameof(M_SudoModeExited)); } diff --git a/SecRandom/Langs/SettingsPages/Security/Resources.en-US.resx b/SecRandom/Langs/SettingsPages/Security/Resources.en-US.resx index dbcbecbbd..6103fabc4 100644 --- a/SecRandom/Langs/SettingsPages/Security/Resources.en-US.resx +++ b/SecRandom/Langs/SettingsPages/Security/Resources.en-US.resx @@ -111,4 +111,9 @@ Invalid code. TOTP settings were not saved. USB binding updated. Unable to update USB binding. Confirm that the drive is connected and writable. + Sudo Duration + How long sudo mode remains active after a successful verification; the settings page does not follow this duration and expires when closed + seconds + Exit Sudo Mode + Sudo mode deactivated diff --git a/SecRandom/Langs/SettingsPages/Security/Resources.ja-JP.resx b/SecRandom/Langs/SettingsPages/Security/Resources.ja-JP.resx index 897ea9a61..fc6ef53b9 100644 --- a/SecRandom/Langs/SettingsPages/Security/Resources.ja-JP.resx +++ b/SecRandom/Langs/SettingsPages/Security/Resources.ja-JP.resx @@ -110,5 +110,10 @@ TOTP 設定を保存しました。 コードが無効です。TOTP 設定は保存されませんでした。 USB 登録を更新しました。 - USB 登録を更新できません。ドライブの接続と書き込み権限を確認してください。 +USB 登録を更新できません。ドライブの接続と書き込み権限を確認してください。 + Sudo 有効期間 + 認証後に Sudo モードが有効な期間。設定ページはこの設定に従わず、閉じると無効になります + 秒 + Sudo モードを終了 + Sudo モードを無効にしました diff --git a/SecRandom/Langs/SettingsPages/Security/Resources.resx b/SecRandom/Langs/SettingsPages/Security/Resources.resx index 83090dcc4..fb11c868b 100644 --- a/SecRandom/Langs/SettingsPages/Security/Resources.resx +++ b/SecRandom/Langs/SettingsPages/Security/Resources.resx @@ -140,4 +140,9 @@ 验证码 复制 已复制 + Sudo 有效时长 + 验证通过后的免验证有效时长,设置页面不遵循此设置,关闭设置页面即失效 + 秒 + 退出 Sudo 模式 + 已退出 Sudo 模式 diff --git a/SecRandom/Services/Security/SecurityContracts.cs b/SecRandom/Services/Security/SecurityContracts.cs index 119520abc..ea5f3b6cf 100644 --- a/SecRandom/Services/Security/SecurityContracts.cs +++ b/SecRandom/Services/Security/SecurityContracts.cs @@ -94,6 +94,12 @@ Task AuthorizeSettingsAsync( Task UnbindUsbAsync(string bindingId, CancellationToken cancellationToken = default); Task UnbindUsbAsync(TopLevel xamlRoot, string bindingId, CancellationToken cancellationToken = default); bool TryUpdateSettings(Action update); + bool IsSudoModeActive(); + bool IsGlobalSudoModeActive(); + void DeactivateGlobalSudoMode(); + void DeactivateSudoMode(); + void DeactivateSettingsSudoMode(); + event Action? SudoModeChanged; } public sealed record UsbBindingInfo(string Id, string DisplayName, bool IsPresent); diff --git a/SecRandom/Services/Security/SecurityService.cs b/SecRandom/Services/Security/SecurityService.cs index 19dfae2d0..06b1b9d42 100644 --- a/SecRandom/Services/Security/SecurityService.cs +++ b/SecRandom/Services/Security/SecurityService.cs @@ -33,6 +33,10 @@ internal sealed class SecurityService( private readonly SemaphoreSlim _authorizationGate = new(1, 1); private string? _pendingTotpSecret; private SecurityCredentialContext? _pendingTotpContext; + private DateTimeOffset? _sudoModeExpirationUtc; + private bool _settingsSudoActive; + + public event Action? SudoModeChanged; private SecuritySettingsConfig Settings => configHandler.Data.SecuritySettings; @@ -62,6 +66,18 @@ public bool RequiresVerification(SecurityOperation operation) if (!Settings.SecurityEnabled) return false; + // Check the appropriate sudo mode based on operation + if (operation == SecurityOperation.OpenSettings) + { + if (_settingsSudoActive) + return false; + } + else + { + if (_sudoModeExpirationUtc is { } expiration && _timeProvider.GetUtcNow() < expiration) + return false; + } + var metadata = credentialStore.LoadMetadata(); if (!metadata.IsReadable) return true; @@ -128,6 +144,8 @@ public async Task AuthorizeAsync( return false; } + ActivateSudoMode(); + await action(); return true; } @@ -195,6 +213,9 @@ public async Task AuthorizeSettingsAsync( return new SecurityAuthorizationResult(false); } + ActivateSudoMode(); + _settingsSudoActive = true; + await action(); return new SecurityAuthorizationResult(true); } @@ -209,6 +230,18 @@ public Task UpdateSecuritySettingsAsync( Action update, CancellationToken cancellationToken = default) { + if (_settingsSudoActive) + { + lock (_gate) + { + update(); + var metadata = credentialStore.LoadMetadata(); + NormalizeSettings(metadata); + configHandler.Save(); + } + return Task.FromResult(true); + } + return AuthorizePasswordCoreAsync(xamlRoot, context => { lock (_gate) @@ -218,6 +251,7 @@ public Task UpdateSecuritySettingsAsync( configHandler.Save(); } + _settingsSudoActive = true; return Task.FromResult(false); }, cancellationToken); } @@ -984,6 +1018,48 @@ private void DisableOperationProtections() Settings.ProtectLinkage = false; } + public bool IsSudoModeActive() + { + if (_settingsSudoActive) + return true; + + if (_sudoModeExpirationUtc is { } expiration) + return _timeProvider.GetUtcNow() < expiration; + + return false; + } + + public bool IsGlobalSudoModeActive() + { + if (_sudoModeExpirationUtc is { } expiration) + return _timeProvider.GetUtcNow() < expiration; + return false; + } + + public void DeactivateGlobalSudoMode() + { + _sudoModeExpirationUtc = null; + SudoModeChanged?.Invoke(); + } + + public void DeactivateSudoMode() + { + _settingsSudoActive = false; + _sudoModeExpirationUtc = null; + SudoModeChanged?.Invoke(); + } + + public void DeactivateSettingsSudoMode() + { + _settingsSudoActive = false; + } + + private void ActivateSudoMode() + { + _sudoModeExpirationUtc = _timeProvider.GetUtcNow().AddSeconds(Settings.SudoModeDurationSeconds); + SudoModeChanged?.Invoke(); + } + private UsbDriveInfo? FindDevice(string deviceId) { if (string.IsNullOrWhiteSpace(deviceId)) diff --git a/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml b/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml index 24ddc7ee2..670d7c6fb 100644 --- a/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml +++ b/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml @@ -95,6 +95,23 @@ + + + + + + + + + Settings.PasswordEnabled, value => Settings.PasswordEnabled = value), @@ -59,6 +60,7 @@ public SecuritySettingsPage() public string TotpButtonText { get; private set; } = SR.C_SetTotp; public bool IsLockedOut { get; private set; } public string LockoutText { get; private set; } = string.Empty; + public double SudoModeDurationValue { get; set; } event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged { @@ -277,6 +279,18 @@ private async void ManageUsb_OnClick(object? sender, RoutedEventArgs e) RefreshSecurityState(); } + private void SudoModeDuration_OnValueChanged(object? sender, NumericUpDownValueChangedEventArgs e) + { + if (_refreshing) + return; + + if (e.NewValue is { } newValue && (int)newValue != Settings.SudoModeDurationSeconds) + { + Settings.SudoModeDurationSeconds = (int)newValue; + ConfigHandler.Save(); + } + } + private async Task ApplySecuritySettingsUpdateAsync(TopLevel xamlRoot, Action update, Action restoreView) { _refreshing = true; diff --git a/SecRandom/Views/SettingsView.axaml.cs b/SecRandom/Views/SettingsView.axaml.cs index 6ef86887e..4f6d1f9c3 100644 --- a/SecRandom/Views/SettingsView.axaml.cs +++ b/SecRandom/Views/SettingsView.axaml.cs @@ -100,6 +100,7 @@ public SettingsView() RestorePreviewControls(); if (_isMobile) RefreshMobileDrawSessions(); + IAppHost.TryGetService()?.DeactivateSettingsSudoMode(); if (ReferenceEquals(Current, this)) Current = null; }; From 45630df5d66fc056cdd048acd6b84cb7c5f6f4c7 Mon Sep 17 00:00:00 2001 From: CreeperAWA Date: Sat, 19 Sep 2026 15:38:46 +0800 Subject: [PATCH 2/5] =?UTF-8?q?docs(AGENTS.md):=20=E6=9B=B4=E6=96=B0sudo?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E7=9B=B8=E5=85=B3=E6=96=87=E6=A1=A3=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正并补充了设置页面独立sudo生命周期的描述,明确其触发时机和生效范围 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c62a7b376..746818827 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ Keep this map short and stable. When code moves, AI agents should re-read the mo - Security credentials must never be stored in `MainConfigModel` or `settings.json`. Keep passwords, TOTP seeds, USB binding tokens, and lockout state in `SecRandom/Services/Security`'s separate credential store; ordinary settings only select factors and protected operations. The sole credential file is `data/config/security/credentials.json`: its internal `FormatVersion` is authoritative, uses Argon2id-derived key material plus AES-256-GCM for portable storage, and must not depend on DPAPI, Keychain, `secret-tool`, or a versioned filename. Do not read or migrate older credential files. - OmniTTS cloud speech API keys are credentials too: they live in `data/config/voice/omnitts-keys.json` via `OmniTtsCredentialStore` (app-layer, atomic replace), never in `VoiceSettingsConfig`/`settings.json`, IPC payloads, logs, telemetry, backups, or the Android DocumentsProvider. OmniTTS is voice engine 2 (`OmniTtsSpeechProvider.OmniEngine`), sits beside System SAPI (0) and Edge TTS (1), and is a unified cloud TTS engine whose providers are OpenAI, Gemini, FishAudio, MiMo, and Custom OpenAI-compatible. OpenAI, Gemini, and MiMo voice choices use documented preset sets; Gemini uses native `/v1beta/interactions` synthesis and wraps its 24 kHz PCM response as WAV. MiMo voice-design and voice-clone models use model-specific chat-completions payloads; clone references are WAV files kept privately under `data/config/voice/mimo-voice-reference.wav`, identified by a hash, and excluded from settings, archives, IPC, and logs. Models are fetched from provider APIs or typed manually. The voice settings page shows OmniTTS-specific rows (provider, base URL, key, model, voice, batch cache) only while `VoiceEngine == 2`. `data/config/voice/` is excluded from archives and protected from the Android provider/directory launcher. iOS keeps the voice settings page unregistered (`!OperatingSystem.IsIOS()`); OmniTTS targets Windows, Linux, macOS, and Android. - Security authorization always flows through `ISecurityService`; do not add direct validation checks to tray handlers, windows, ViewModels, or linkage code. Passwords require at least 6 characters, with no artificial character-class rule. -- Sudo mode is always enabled: after a successful authentication, protected operations skip re-verification for a configurable duration (`SudoModeDurationSeconds`, default 60s). The settings page has its own independent sudo lifetime: once verified while the settings window is open, all subsequent operations within that session are免验证 until the window closes. TOTP setup (`BeginTotpSetupAsync`) must always require fresh verification and must never be bypassed by sudo mode. The tray menu exposes an "Exit Sudo Mode" option (visible only when global sudo is active) that calls `DeactivateGlobalSudoMode()` to clear the global timer only; it cannot clear the settings-page sudo state. +- Sudo mode is always enabled: after a successful authentication for draw/window operations, protected operations skip re-verification for a configurable duration (`SudoModeDurationSeconds`, default 60s). The settings page has its own independent sudo lifetime: it activates after successful verification when opening settings (if "Open Settings" protected) OR on first setting change requiring verification (if "Open Settings" unprotected). Once active, all subsequent settings operations skip verification until the settings window closes. TOTP setup (`BeginTotpSetupAsync`) must always require fresh verification and must never be bypassed by sudo mode. The tray menu exposes an "Exit Sudo Mode" option (visible only when global sudo is active) that calls `DeactivateGlobalSudoMode()` to clear the global timer only; it cannot clear the settings-page sudo state. - Full IPC/URL compatibility is app-layer routed through `ProtocolCommandRouter`: structured current-user named-pipe IPC is additive to legacy `ShowMainWindow`/`Restart`/`Url:` delivery, and all external mutations must use `ISecurityService`. `data/*` queries must use non-mutating profile snapshots, never active-profile loading APIs. - `HistoryItem.DrawRoundId` identifies every record committed by one logical draw. Populate it for new history writes; IPC history projections group by it and must never expose internal `RecordId`. - Draw commits must go through `IDrawCommitService` (`DrawCommitCoordinator`): one logical draw gets exactly one `DrawRoundId` (a caller may supply one), temporary records commit before persistent history, a mid-commit failure rolls back through snapshot compensation, and commits serialize behind the coordinator gate. Never reintroduce bare two-step writes of `IProfileService.Record*History` plus temporary-record calls; the optional `drawRoundId` parameter (and `drawMethod` on `RecordPrizeHistory`) exists for coordinator use. From 57d0dae5741af4ce01d22b546f5745eb0a13251e Mon Sep 17 00:00:00 2001 From: CreeperAWA Date: Sat, 19 Sep 2026 15:38:54 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(SecurityService):=20=E4=BF=AE=E5=A4=8Ds?= =?UTF-8?q?udo=E6=A8=A1=E5=BC=8F=E8=BF=87=E6=9C=9F=E6=9C=AA=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E8=A7=A6=E5=8F=91=E5=9B=9E=E8=B0=83=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当sudo模式过期时,正确重置过期时间并触发状态变更通知,完善过期判断后的清理逻辑 --- SecRandom/Services/Security/SecurityService.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/SecRandom/Services/Security/SecurityService.cs b/SecRandom/Services/Security/SecurityService.cs index 06b1b9d42..7b46b0ef5 100644 --- a/SecRandom/Services/Security/SecurityService.cs +++ b/SecRandom/Services/Security/SecurityService.cs @@ -1032,7 +1032,16 @@ public bool IsSudoModeActive() public bool IsGlobalSudoModeActive() { if (_sudoModeExpirationUtc is { } expiration) - return _timeProvider.GetUtcNow() < expiration; + { + var now = _timeProvider.GetUtcNow(); + if (now >= expiration) + { + _sudoModeExpirationUtc = null; + SudoModeChanged?.Invoke(); + return false; + } + return true; + } return false; } From 599a67561e69d5c950c4e9cfc2318797467c329a Mon Sep 17 00:00:00 2001 From: CreeperAWA Date: Sat, 19 Sep 2026 16:47:59 +0800 Subject: [PATCH 4/5] feat(tray): add sudo mode auto-refresh and separator management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增托盘菜单sudo模式选项的分隔符管理和定时刷新功能,修复sudo模式状态更新不及时的问题,自动控制分隔符的显示与隐藏 --- SecRandom/App.axaml.cs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index e47f3850e..9369e1ccb 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -115,6 +115,8 @@ public partial class App : Application private static Task? _runtimeServicesStartupTask; private NativeMenuItem? _floatingWindowMenuItem; private NativeMenuItem? _exitSudoModeMenuItem; + private NativeMenuItem? _exitSudoModeSeparator; + private Timer? _sudoModeRefreshTimer; private static IClassicDesktopStyleApplicationLifetime? _desktopLifetime; private IHost? _mobileHost; private ISingleViewApplicationLifetime? _singleViewLifetime; @@ -1121,10 +1123,13 @@ public void InitializeApp() taskBarIconService.MainTaskBarIcon.Menu = menu; _floatingWindowMenuItem = menu?.Items.ElementAtOrDefault(3) as NativeMenuItem; _exitSudoModeMenuItem = menu?.Items.ElementAtOrDefault(9) as NativeMenuItem; + _exitSudoModeSeparator = menu?.Items.ElementAtOrDefault(8) as NativeMenuItem; RefreshTrayWindowMenuItems(); RefreshSudoMenuItem(); IAppHost.GetService().SudoModeChanged += RefreshSudoMenuItem; + + _sudoModeRefreshTimer = new Timer(_ => Dispatcher.UIThread.Post(RefreshSudoMenuItem), null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); taskBarIconService.MainTaskBarIcon.IsVisible = true; taskBarIconService.MainTaskBarIcon.Clicked += MainTaskBarIconOnClicked; IAppHost.GetService().EnsureConfiguredIntegrations(); @@ -1148,6 +1153,8 @@ public async Task StopAsync() private async Task StopAsync(bool requestLifetimeShutdown) { IAppHost.TryGetService().SudoModeChanged -= RefreshSudoMenuItem; + _sudoModeRefreshTimer?.Dispose(); + _sudoModeRefreshTimer = null; lock (_shutdownGate) { @@ -2157,7 +2164,27 @@ private void RefreshSudoMenuItem() if (_exitSudoModeMenuItem is not null) { var securityService = IAppHost.GetService(); - _exitSudoModeMenuItem.IsVisible = securityService.IsGlobalSudoModeActive(); + var isVisible = securityService.IsGlobalSudoModeActive(); + _exitSudoModeMenuItem.IsVisible = isVisible; + + if (_exitSudoModeSeparator is not null) + { + var menu = this.FindResource(@"AppMenu") as NativeMenu; + if (menu is not null) + { + if (isVisible && !menu.Items.Contains(_exitSudoModeSeparator)) + { + // Insert separator before Exit Sudo Mode item + var index = menu.Items.IndexOf(_exitSudoModeMenuItem); + if (index >= 0) + menu.Items.Insert(index, _exitSudoModeSeparator); + } + else if (!isVisible && menu.Items.Contains(_exitSudoModeSeparator)) + { + menu.Items.Remove(_exitSudoModeSeparator); + } + } + } } } From 945827b5efbcebb4a720dd5692102c57a60102f0 Mon Sep 17 00:00:00 2001 From: CreeperAWA Date: Fri, 25 Sep 2026 10:11:59 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=B8=AD=20sudo=20=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E9=80=BB=E8=BE=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除了在设置 sudo 激活状态前调用的 ActivateSudoMode() --- SecRandom/Services/Security/SecurityService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/SecRandom/Services/Security/SecurityService.cs b/SecRandom/Services/Security/SecurityService.cs index 7b46b0ef5..7f803973d 100644 --- a/SecRandom/Services/Security/SecurityService.cs +++ b/SecRandom/Services/Security/SecurityService.cs @@ -213,7 +213,6 @@ public async Task AuthorizeSettingsAsync( return new SecurityAuthorizationResult(false); } - ActivateSudoMode(); _settingsSudoActive = true; await action();