From 83c03273b59d2042083e01efa179e9e09f80627d Mon Sep 17 00:00:00 2001 From: Napreth Date: Fri, 6 Nov 2026 03:56:58 +0800 Subject: [PATCH 1/5] fix: stabilize editing and configurable PATH monitoring --- .github/workflows/build.yml | 3 + PathMan.slnx | 1 + README.md | 8 +- .../ElevatedEnvironmentSession.cs | 17 +- .../EnvironmentMonitor.cs | 16 +- .../EnvironmentMonitorNotices.cs | 3 + .../EnvironmentPathCleanup.cs | 25 ++- .../EnvironmentVariableClassifier.cs | 3 +- .../EnvironmentWorkspace.cs | 5 + .../ExternalPathOrganizer.cs | 19 +- .../SoftwarePathReferenceHealthCheck.cs | 18 +- .../WindowsEnvironmentVariableEditor.cs | 8 +- src/Pathman.Core/GlobalMonitoring.cs | 21 ++- src/Pathman.Core/Localization/Strings.resx | 32 ++-- .../Localization/Strings.zh-CN.resx | 32 ++-- .../Versions/CommandLinePathSetting.cs | 18 +- src/Pathman.Gui/EnvironmentEditHost.cs | 25 ++- src/Pathman.Gui/EnvironmentEntry.cs | 15 +- src/Pathman.Gui/MainWindow.xaml.cs | 24 ++- src/Pathman.Gui/Views/MonitorView.Targets.cs | 80 ++++++++ src/Pathman.Gui/Views/MonitorView.xaml | 18 +- src/Pathman.Gui/Views/MonitorView.xaml.cs | 15 +- .../Views/VariablesView.Cleanup.cs | 4 +- src/Pathman.Gui/Views/VariablesView.Live.cs | 2 +- src/Pathman.Gui/Views/VariablesView.Paths.cs | 13 +- src/Pathman.Gui/Views/VariablesView.xaml | 2 +- src/Pathman.Gui/Views/VariablesView.xaml.cs | 59 ++++-- src/Pathman.Gui/Views/VersionsView.xaml.cs | 2 +- src/Pathman.Service/Program.cs | 8 +- .../CommandLinePathTests.cs | 16 +- .../MonitoringTargetTests.cs | 49 +++++ tests/Pathman.Core.Tests/PathCleanupTests.cs | 6 +- tests/Pathman.Core.Tests/Program.cs | 1 + .../Pathman.Gui.Tests.csproj | 10 + tests/Pathman.Gui.Tests/Program.cs | 178 ++++++++++++++++++ tests/global-monitor.ps1 | 20 +- 36 files changed, 633 insertions(+), 143 deletions(-) create mode 100644 src/Pathman.Gui/Views/MonitorView.Targets.cs create mode 100644 tests/Pathman.Core.Tests/MonitoringTargetTests.cs create mode 100644 tests/Pathman.Gui.Tests/Pathman.Gui.Tests.csproj create mode 100644 tests/Pathman.Gui.Tests/Program.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8e4ef01..623e8d1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -95,6 +95,9 @@ jobs: if: matrix.arch == 'x64' shell: pwsh -NoProfile -ExecutionPolicy Bypass -File {0} run: ./tests/shell-switch.ps1 + - name: Test editing and monitoring controls + if: matrix.arch == 'x64' + run: dotnet run --project tests/Pathman.Gui.Tests -c Release - name: Build baseline installers if: ${{ !env.ACT }} run: ./build.ps1 -Arch $env:PATHMAN_ARCH -VersionName '0.0.0' -VersionNumber '0.0.0' -ReleaseType official diff --git a/PathMan.slnx b/PathMan.slnx index b7706f0..584600a 100644 --- a/PathMan.slnx +++ b/PathMan.slnx @@ -11,5 +11,6 @@ + diff --git a/README.md b/README.md index 5b83fa7..ac9da41 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ PATH、PATHEXT 等分号列表支持多行与文本两种编辑模式。多行 变量管理页右上角的“清理”会打开双栏差异预览,点击“应用清理”后写入。 - 用户 PATH:展开引用后去重。 -- 系统 PATH:去重后保留 Windows 系统目录与纯引用,将其余软件路径移入 `SOFTWARE_PATH` 并添加引用。名称已占用时使用 `SOFTWARE1_PATH`、`SOFTWARE2_PATH` 等。 +- 系统 PATH:去重后保留 Windows 系统目录与纯引用,将其余软件路径移入所选整理目标并添加引用,默认使用 `EXTERNAL_PATH`。目标变量已有内容时追加路径。 - 直接条目与被引用变量中的路径重复时,优先保留引用,被引用变量的内容不变。 预览会提示搜索顺序是否改变,因为 PATH 顺序可能影响实际运行的命令。写入前备份原值和注册表类型;源值或引用发生变化后需重新预览。 @@ -94,9 +94,9 @@ PATH 不可作为版本目标。内置系统变量默认不可选,可在“设 后台监控需要同时开启“在后台运行监控程序”和“登录时自动运行后台程序”。关闭后台运行会关闭登录自启,其余选项保留并置灰。主窗口的实时监听独立运行,不执行后台自动整理。 -用户和系统范围分别提供外部变量归类、PATH 去重与迁移选项,也可排除系统环境编辑器或 PowerToys 的修改。用户外部路径迁入 `EXTERNAL_USER_PATH`,系统外部路径迁入 `EXTERNAL_PATH`。 +用户和系统范围分别提供外部变量归类、PATH 去重与迁移选项,也可排除系统环境编辑器或 PowerToys 的修改。归类目标和路径迁移目标可在下拉菜单中选择,路径目标也支持输入新变量名。默认迁入 `EXTERNAL_USER_PATH` 和 `EXTERNAL_PATH`;已有 `SOFTWARE_PATH` 及其引用继续保留,并参与展开去重。全局系统 PATH 整理有独立的迁移目标。 -“精确识别修改来源”首次启用时需要管理员授权,之后由计划任务启动只读的来源采集程序,登录时不会重复弹出 UAC。管理员本人的采集任务使用其提权令牌,跨用户授权的采集任务使用 SYSTEM。监控主进程仍以当前用户权限运行。来源无法确认时保留原值,个人模式下的系统 PATH 自动整理仍需单独授权。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。 +“精确识别修改来源”首次启用时需要管理员授权,之后由计划任务启动只读的来源采集程序,登录时不会重复弹出 UAC。管理员本人的采集任务使用其提权令牌,跨用户授权的采集任务使用 SYSTEM。监控主进程仍以当前用户权限运行。来源无法确认时保留原值。个人模式启用系统 PATH 自动整理时申请写入授权,主窗口存活期间复用该授权;关闭窗口后结束写入会话。来源识别授权与系统写入授权分别显示。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。 为所有用户安装时,“系统变量”下面还提供“全局设置”。开启“为所有用户开启统一设置”后,由一个自动启动的 Windows 服务承担监控与来源采集,个人后台程序退出;个人的系统 PATH 整理选项停用,其保存的值保留。全局设置可分别启用非管理员用户的精确来源识别和系统 PATH 自动整理,配置、机器级记录及备份保存在受保护的 `%ProgramData%\PathMan\monitor`。服务按用户令牌处理各自的用户变量与个人配置。关闭统一设置后,恢复符合个人后台运行和登录自启条件的后台程序。仅为自己安装时不显示全局设置。 @@ -128,7 +128,7 @@ PATH 不可作为版本目标。内置系统变量默认不可选,可在“设 ## 版本管理 CLI -在版本管理页开启“启用命令行版本切换”后,打开新终端使用 `pathman`。该功能默认关闭,主窗口按安装范围将 `bin` 加入用户或系统 PATH;相应范围启用自动整理时,写入 `SOFTWARE_PATH` 或已有的 `SOFTWARE数字_PATH`。PATH 缺少该变量的纯引用时,健康检测会报告错误。 +在版本管理页开启“启用命令行版本切换”后,打开新终端使用 `pathman`。该功能默认关闭,主窗口按安装范围将 `bin` 加入用户或系统 PATH;相应范围启用自动整理时,写入所选迁移目标,并在 PATH 中补上该变量的引用。 关闭开关会移除命令行目录并保留版本配置。未启用时直接运行版本命令会报错退出,`--help` 始终可用。 diff --git a/src/Pathman.Core/EnvironmentVariables/ElevatedEnvironmentSession.cs b/src/Pathman.Core/EnvironmentVariables/ElevatedEnvironmentSession.cs index db60c15..981ab6e 100644 --- a/src/Pathman.Core/EnvironmentVariables/ElevatedEnvironmentSession.cs +++ b/src/Pathman.Core/EnvironmentVariables/ElevatedEnvironmentSession.cs @@ -15,8 +15,9 @@ namespace Pathman.Core.EnvironmentVariables; internal sealed record EnvironmentSessionRequest(EnvironmentMutation[] Mutations,VariableSnapshot[] Preconditions); -internal sealed record EnvironmentSessionResult(string? Error); -public sealed class EnvironmentOperationException(string message) : IOException(message); +internal sealed record EnvironmentSessionResult(string? Error,bool Conflict=false); +public class EnvironmentOperationException(string message) : IOException(message); +public sealed class EnvironmentConflictException(string message) : EnvironmentOperationException(message); public sealed class ElevatedEnvironmentSession : IDisposable { private readonly NamedPipeServerStream pipe; @@ -61,7 +62,7 @@ public async Task ApplyAsync(IReadOnlyList mutations,IReadO { await WriteAsync(pipe,new EnvironmentSessionRequest(mutations.ToArray(),preconditions.ToArray()),timeout.Token); var result=await ReadAsync(pipe,timeout.Token); - if(result.Error is not null) throw new EnvironmentOperationException(result.Error); + if(result.Error is not null) throw result.Conflict ? new EnvironmentConflictException(result.Error) : new EnvironmentOperationException(result.Error); } catch(OperationCanceledException error) {throw new IOException(L.Get("管理员连接超时,请刷新确认写入结果后重试。"),error);} catch(EndOfStreamException error) {throw new IOException(L.Get("管理员连接已断开,请刷新确认写入结果后重试。"),error);} @@ -85,17 +86,19 @@ public static async Task RunWorkerAsync(string[] arguments) var parentExit=parent.WaitForExitAsync(); while(!parent.HasExited) { - using var idle=new CancellationTokenSource(TimeSpan.FromMinutes(15)); + // Authorization belongs to this GUI process and ends with its pipe + // or lifetime, rather than expiring silently during a long edit. + using var idle=new CancellationTokenSource(); var reading=ReadAsync(client,idle.Token); if(await Task.WhenAny(reading,parentExit)!=reading) {idle.Cancel();try {await reading;}catch(OperationCanceledException){}return 0;} EnvironmentSessionRequest request; try {request=await reading;}catch(OperationCanceledException){return 0;} - string? error=null; + string? error=null;var conflict=false; try {WindowsEnvironmentVariableEditor.ApplyMachineRegistry(request.Mutations,request.Preconditions);} catch(Exception failure) when(failure is IOException or UnauthorizedAccessException or ArgumentException or System.Security.SecurityException) - {error=failure.Message;} - await WriteAsync(client,new EnvironmentSessionResult(error),idle.Token); + {error=failure.Message;conflict=failure is EnvironmentConflictException;} + await WriteAsync(client,new EnvironmentSessionResult(error,conflict),idle.Token); } return 0; } diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs index dd9656a..bf1206d 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs @@ -22,6 +22,11 @@ public void SetEnabled(bool enabled,IReadOnlyList? capt if (enabled) state.Snapshot = (captured ?? ReadCurrent()).Select(Snapshot).ToList(); }); public bool Poll(IEnvironmentChangeOrigins? origins=null,IReadOnlyList? captured=null) + { + if(!RefreshPlans(origins,captured)) return false; + return ApplyPlans(origins); + } + public bool RefreshPlans(IEnvironmentChangeOrigins? origins=null,IReadOnlyList? captured=null) { var paused=false; store.Inspect(state=> @@ -33,7 +38,10 @@ public bool Poll(IEnvironmentChangeOrigins? origins=null,IReadOnlyList { if(!state.MonitoringEnabled || ExternalPathOrganizer.Policy(state,plan.Scope)!=plan.Policy || !state.PendingPathOrganizations.Any(item=>item.Id==plan.Id)) - throw new IOException(L.Get("监控设置或待整理项目已改变,本次整理已跳过。")); + throw new EnvironmentConflictException(L.Get("监控设置或待整理项目已改变,本次整理已跳过。")); }); Complete(store,plan); applied=true; } + catch(EnvironmentConflictException) { /* The next observation rebuilds the guarded plan. */ } catch(Exception error) when(error is IOException or UnauthorizedAccessException or System.Security.SecurityException or ArgumentException) {failedPlans.Add(plan.Id);ReportError(L.Get("自动整理未完成:")+error.Message);} } @@ -85,7 +94,8 @@ internal static bool PreparePlans(EnvironmentWorkspace state,IReadOnlyListcurrent.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals(mutation.Name,StringComparison.OrdinalIgnoreCase))?.RawValue==mutation.ExpectedValue)) continue; + pending.Mutations.All(mutation=>current.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals(mutation.Name,StringComparison.OrdinalIgnoreCase))?.RawValue==mutation.ExpectedValue) && + pending.Preconditions.All(guard=>current.Any(item=>item.Scope==guard.Scope && item.Name.Equals(guard.Name,StringComparison.OrdinalIgnoreCase) && item.RawValue==guard.Value && (int)item.Kind==guard.Kind))) continue; var plan=ExternalPathOrganizer.Plan(scope,pending is null ? old : pending.BeforePath,current,state,runtime) with {Origins=writers}; state.PendingPathOrganizations.RemoveAll(item=>item.Scope==scope); if(plan.Mutations.Count>0) state.PendingPathOrganizations.Add(plan); diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitorNotices.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitorNotices.cs index 7aa7555..044d024 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitorNotices.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitorNotices.cs @@ -18,6 +18,9 @@ public static bool Reconcile(EnvironmentWorkspace state) { var unknown=Text.IsTranslation(UnknownOriginMessage,state.MonitorMessage); var changed=false; + if(!unknown && state.MonitorMessage is not null && state.Changes.Any(item=>item.IsOwn && IsPath(item.Name) && + (state.MonitorMessageTime is null || item.Time>=state.MonitorMessageTime))) + {state.MonitorMessage=null;state.MonitorMessageTime=null;changed=true;unknown=false;} // Older workspaces stored only a message, with no event identity. if(unknown && state.UnattributedPathChanges.Count==0) { diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentPathCleanup.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentPathCleanup.cs index 638ddf9..cae7f1c 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentPathCleanup.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentPathCleanup.cs @@ -4,7 +4,7 @@ namespace Pathman.Core.EnvironmentVariables; public sealed record PathCleanupScopePlan(VariableScope Scope, IReadOnlyList Mutations, - IReadOnlyList Preconditions, int RemovedDuplicates, int MovedEntries, string? SoftwareVariable) + IReadOnlyList Preconditions, int RemovedDuplicates, int MovedEntries, string? TargetVariable) {public bool ChangesPathOrder {get;init;} public bool PathOrderResolved {get;init;}=true;} public sealed record EnvironmentPathCleanupPlan(IReadOnlyList Scopes) { @@ -18,12 +18,10 @@ private sealed record Entry(string Raw, EnvironmentPathExpansion Expansion, stri private static readonly string[] BuiltInPaths = EnvironmentWorkspaceStore.ReadDefault("windows-paths").Paths; public static EnvironmentPathCleanupPlan Analyze(IReadOnlyList variables, - IReadOnlyDictionary? runtime = null) + IReadOnlyDictionary? runtime = null,EnvironmentWorkspace? settings=null) { var resolver = new EnvironmentPathResolver(variables, runtime); var plans = new List(); - var reserved = variables.Select(item => item.Name).Concat(runtime?.Keys ?? []) - .Concat(variables.SelectMany(item => EnvironmentPathResolver.ReferenceNames(item.RawValue))).ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var scope in new[] { VariableScope.Machine, VariableScope.User }) { var path = variables.FirstOrDefault(item => item.Scope == scope && item.Name.Equals("PATH", StringComparison.OrdinalIgnoreCase)); @@ -65,11 +63,22 @@ string[] Keys(EnvironmentPathExpansion expansion) => expansion.IsResolved && exp string? softwareName = null; if (moved.Length > 0) { - var number = 0; - do { softwareName = number == 0 ? "SOFTWARE_PATH" : $"SOFTWARE{number}_PATH"; number++; } while (!reserved.Add(softwareName)); - mutations.Add(new("create", scope, softwareName, string.Join(';', moved.Select(entry => entry.Raw)))); + softwareName=ExternalPathOrganizer.GroupName(scope,settings); + var target=variables.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals(softwareName,StringComparison.OrdinalIgnoreCase)); + moved=moved.Where(entry=>!EnvironmentPathResolver.ReferenceNames(entry.Raw).Contains(softwareName,StringComparer.OrdinalIgnoreCase)).ToArray(); + var value=string.Join(';',(target is null ? [] : target.Entries).Concat(moved.Select(entry=>entry.Raw))); + var candidate=variables.Where(item=>item!=target).Append(new EnvironmentVariableInfo(softwareName,value,scope)).ToArray(); + if(moved.Length==0 || !new EnvironmentPathResolver(candidate,runtime).Expand(value,scope).IsResolved) {moved=[];softwareName=null;} + else + { + mutations.Add(new(target is null ? "create" : "set",scope,softwareName,value,ExpectedValue:target?.RawValue,ExpectedKind:target?.Kind)); + if(target is not null) guards=guards.Concat(new[]{new VariableSnapshot(scope,target.Name,target.RawValue,(int)target.Kind)}) + .Concat(resolver.ReferencedVariables(target.RawValue,scope).Select(item=>new VariableSnapshot(item.Scope,item.Name,item.RawValue,(int)item.Kind))).DistinctBy(item=>EnvironmentWorkspace.Key(item.Scope,item.Name),StringComparer.OrdinalIgnoreCase).ToArray(); + } } - var newPath = string.Join(';', retained.Except(moved).Select(entry => entry.Raw).Concat(softwareName is null ? [] : new[] { $"%{softwareName}%" })); + var remaining=retained.Except(moved).Select(entry=>entry.Raw).ToList(); + if(softwareName is not null && !remaining.Any(entry=>EnvironmentPathResolver.PureReference(entry)?.Equals(softwareName,StringComparison.OrdinalIgnoreCase)==true)) remaining.Add($"%{softwareName}%"); + var newPath = string.Join(';',remaining); if (newPath != path.RawValue) mutations.Add(new("set", scope, path.Name, newPath, ExpectedValue: path.RawValue, ExpectedKind: path.Kind)); var updated=variables.Where(item=>!mutations.Any(change=>change.Scope==item.Scope && change.Name.Equals(item.Name,StringComparison.OrdinalIgnoreCase))) .Concat(mutations.Select(change=>new EnvironmentVariableInfo(change.Name,change.Value ?? "",change.Scope))).ToArray(); diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentVariableClassifier.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentVariableClassifier.cs index a64d700..d11141b 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentVariableClassifier.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentVariableClassifier.cs @@ -43,7 +43,8 @@ public static string Classify(EnvironmentVariableInfo variable, IReadOnlyList group.Id == assigned)) return assigned; - var external = groups.FirstOrDefault(group => group.Rule == "external" && state.MonitoringEnabled && state.ExternalVariables.Contains(key)); + var external = state.MonitoringEnabled && state.ShouldGroupExternalVariables(variable.Scope) && state.ExternalVariables.Contains(key) + ? groups.FirstOrDefault(group=>group.Id==state.ExternalGroupId(variable.Scope)) ?? groups.FirstOrDefault(group=>group.Rule=="external") : null; if (external is not null) return external.Id; if (Defaults.DefaultOtherNames.Contains(variable.Name,StringComparer.OrdinalIgnoreCase)) return groups.First(group=>group.Rule=="fallback").Id; foreach (var group in state.Groups.Where(group => groups.Contains(group))) diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs index bae9ec7..ff04fca 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs @@ -53,6 +53,11 @@ public sealed class EnvironmentWorkspace public string? OriginTrackingMessage {get;set;} public bool GroupExternalUserVariables {get;set;}=true; public bool GroupExternalMachineVariables {get;set;}=true; + public string ExternalUserGroupId {get;set;}="external"; + public string ExternalMachineGroupId {get;set;}="external"; + public string ExternalUserPathVariable {get;set;}="EXTERNAL_USER_PATH"; + public string ExternalMachinePathVariable {get;set;}="EXTERNAL_PATH"; + public string ExternalGroupId(VariableScope scope)=>scope==VariableScope.User ? ExternalUserGroupId : ExternalMachineGroupId; public bool AutoArrangeUserPath {get;set;} public bool AutoArrangeMachinePath {get;set;} [JsonIgnore] public bool AnyPathOrganizationEnabled=>AutoArrangeUserPath || AutoArrangeMachinePath; diff --git a/src/Pathman.Core/EnvironmentVariables/ExternalPathOrganizer.cs b/src/Pathman.Core/EnvironmentVariables/ExternalPathOrganizer.cs index 53980ae..725b64b 100644 --- a/src/Pathman.Core/EnvironmentVariables/ExternalPathOrganizer.cs +++ b/src/Pathman.Core/EnvironmentVariables/ExternalPathOrganizer.cs @@ -13,14 +13,27 @@ public sealed record ExternalPathOrganizationPlan(VariableScope Scope,VariableSn public static class ExternalPathOrganizer { - public static string GroupName(VariableScope scope)=>scope==VariableScope.User ? "EXTERNAL_USER_PATH" : "EXTERNAL_PATH"; - public static string Policy(EnvironmentWorkspace state,VariableScope scope)=>$"{scope}:{state.IsPathOrganizationEnabled(scope)}:{state.ShouldRemovePathDuplicates(scope)}:{state.ShouldMovePathEntries(scope)}:{state.TrackChangeOrigins}:{state.ShouldIgnoreSystemEditor(scope)}:{state.ShouldIgnorePowerToys(scope)}"; + public static string GroupName(VariableScope scope,EnvironmentWorkspace? state=null) + { + var configured=scope==VariableScope.User ? state?.ExternalUserPathVariable : state?.ExternalMachinePathVariable; + return IsValidTarget(configured) ? configured!.Trim() : scope==VariableScope.User ? "EXTERNAL_USER_PATH" : "EXTERNAL_PATH"; + } + public static bool IsValidTarget(string? name) + { + if(string.IsNullOrWhiteSpace(name)) return false; + try {WindowsEnvironmentVariableEditor.ValidateName(name.Trim());} + catch(ArgumentException) {return false;} + return !name.Trim().Equals("Path",StringComparison.OrdinalIgnoreCase) && !EnvironmentVariableClassifier.IsWindowsBuiltIn(name.Trim()); + } + public static void ValidateTarget(string name) + {if(!IsValidTarget(name)) throw new ArgumentException(L.Get("请选择有效的路径变量,不能使用 PATH 或系统内置变量。"));} + public static string Policy(EnvironmentWorkspace state,VariableScope scope)=>$"{scope}:{state.IsPathOrganizationEnabled(scope)}:{state.ShouldRemovePathDuplicates(scope)}:{state.ShouldMovePathEntries(scope)}:{state.TrackChangeOrigins}:{state.ShouldIgnoreSystemEditor(scope)}:{state.ShouldIgnorePowerToys(scope)}:{GroupName(scope,state).ToUpperInvariant()}"; public static ExternalPathOrganizationPlan Plan(VariableScope scope,VariableSnapshot? beforePath, IReadOnlyList current,EnvironmentWorkspace state,IReadOnlyDictionary? runtime=null,string pathName="Path") { ExternalPathOrganizationPlan Empty(string? warning=null)=>new(scope,beforePath,[],[],0,0,Policy(state,scope)) {Warning=warning}; if(!state.IsPathOrganizationEnabled(scope) || !state.ShouldRemovePathDuplicates(scope) && !state.ShouldMovePathEntries(scope)) return Empty(); - var groupName=GroupName(scope); + var groupName=GroupName(scope,state); var path=current.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals(pathName,StringComparison.OrdinalIgnoreCase)); if(path is null) return Empty(); var resolver=new EnvironmentPathResolver(current,runtime); diff --git a/src/Pathman.Core/EnvironmentVariables/HealthChecks/SoftwarePathReferenceHealthCheck.cs b/src/Pathman.Core/EnvironmentVariables/HealthChecks/SoftwarePathReferenceHealthCheck.cs index 8a4c503..c8accc9 100644 --- a/src/Pathman.Core/EnvironmentVariables/HealthChecks/SoftwarePathReferenceHealthCheck.cs +++ b/src/Pathman.Core/EnvironmentVariables/HealthChecks/SoftwarePathReferenceHealthCheck.cs @@ -1,24 +1,26 @@ using Pathman.Core.Models; -using Pathman.Core.Versions; namespace Pathman.Core.EnvironmentVariables; public sealed class SoftwarePathReferenceHealthCheck : IEnvironmentHealthCheck { public string Id=>"software-path-reference"; - public string Title=>L.Get("软件路径引用"); + public string Title=>L.Get("整理目标路径引用"); public IEnumerable Check(EnvironmentHealthContext context) { foreach(var scope in new[]{VariableScope.User,VariableScope.Machine}) { - if(context.Workspace is not {MonitoringEnabled:true} workspace || !workspace.IsPathOrganizationEnabled(scope)) continue; + if(context.Workspace is not {MonitoringEnabled:true} workspace || !workspace.IsPathOrganizationEnabled(scope) || !workspace.ShouldMovePathEntries(scope)) continue; + var target=ExternalPathOrganizer.GroupName(scope,workspace); + var group=context.Variables.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals(target,StringComparison.OrdinalIgnoreCase)); + if(string.IsNullOrEmpty(group?.RawValue)) continue; var path=context.Variables.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals("Path",StringComparison.OrdinalIgnoreCase)); - if(path?.Entries.Select(EnvironmentPathResolver.PureReference).Any(name=>name is not null && CommandLinePathSetting.IsSoftwareVariable(name))==true) continue; - var sources=context.Variables.Where(item=>item.Scope==scope && (item==path || CommandLinePathSetting.IsSoftwareVariable(item.Name))) + if(path?.Entries.Select(EnvironmentPathResolver.PureReference).Any(name=>name?.Equals(target,StringComparison.OrdinalIgnoreCase)==true)==true) continue; + var sources=context.Variables.Where(item=>item.Scope==scope && (item==path || item==group)) .Select(item=>new VariableSnapshot(scope,item.Name,item.RawValue,(int)item.Kind)).ToArray(); - yield return new(EnvironmentIssueKind.Custom,scope,path?.Name ?? "Path",L.Get("已启用自动整理,但 PATH 未引用 SOFTWARE_PATH 或 SOFTWARE数字_PATH。")) { - IsError=true,IdentityMessage="missing-software-path-reference",Sources=sources, - Suggestion=L.Get("在相应作用域的 PATH 中添加软件路径变量的纯引用,例如 %SOFTWARE_PATH%。")}; + yield return new(EnvironmentIssueKind.Custom,scope,path?.Name ?? "Path",L.Format("整理目标 {0} 中已有路径,但 PATH 尚未引用它。",target)) { + IsError=true,IdentityMessage="missing-path-target-reference:"+target.ToUpperInvariant(),Sources=sources, + Suggestion=L.Format("在相应作用域的 PATH 中添加 %{0}%。",target)}; } } } diff --git a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs index 3b1baed..353d791 100644 --- a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs +++ b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs @@ -80,7 +80,7 @@ internal static Action ApplyBatchKeys(IReadOnlyDictionaryplan.Scope==mutation.Scope && + (mutation.Name.Equals("Path",StringComparison.OrdinalIgnoreCase) || plan.Preconditions.Any(guard=>guard.Scope==mutation.Scope && guard.Name.Equals(mutation.Name,StringComparison.OrdinalIgnoreCase)))); var oldKey = EnvironmentWorkspace.Key(mutation.Scope, mutation.Name); state.Snapshot.RemoveAll(item => item.Scope == mutation.Scope && item.Name.Equals(mutation.Name, StringComparison.OrdinalIgnoreCase)); state.ExternalVariables.Remove(oldKey); diff --git a/src/Pathman.Core/GlobalMonitoring.cs b/src/Pathman.Core/GlobalMonitoring.cs index b8ca98f..754fe39 100644 --- a/src/Pathman.Core/GlobalMonitoring.cs +++ b/src/Pathman.Core/GlobalMonitoring.cs @@ -15,14 +15,16 @@ public sealed record GlobalMonitorSettings(bool Enabled=false,bool ProvideNonAdm public bool MoveExternalMachinePathEntries {get;init;}=true; public bool IgnoreSystemEditorMachineChanges {get;init;} public bool IgnorePowerToysMachineChanges {get;init;} + public string ExternalMachinePathVariable {get;init;}="EXTERNAL_PATH"; public bool ApplyMachineOptions(EnvironmentWorkspace state) { var changed=!state.MonitoringEnabled || !state.TrackChangeOrigins || state.AutoArrangeMachinePath!=AutoArrangeMachinePath || state.RemoveMachinePathDuplicates!=RemoveMachinePathDuplicates || state.MoveExternalMachinePathEntries!=MoveExternalMachinePathEntries || - state.IgnoreSystemEditorMachineChanges!=IgnoreSystemEditorMachineChanges || state.IgnorePowerToysMachineChanges!=IgnorePowerToysMachineChanges; + state.IgnoreSystemEditorMachineChanges!=IgnoreSystemEditorMachineChanges || state.IgnorePowerToysMachineChanges!=IgnorePowerToysMachineChanges || state.ExternalMachinePathVariable!=ExternalMachinePathVariable; state.MonitoringEnabled=true;state.TrackChangeOrigins=true;state.AutoArrangeMachinePath=AutoArrangeMachinePath; state.RemoveMachinePathDuplicates=RemoveMachinePathDuplicates;state.MoveExternalMachinePathEntries=MoveExternalMachinePathEntries; state.IgnoreSystemEditorMachineChanges=IgnoreSystemEditorMachineChanges;state.IgnorePowerToysMachineChanges=IgnorePowerToysMachineChanges; + state.ExternalMachinePathVariable=ExternalMachinePathVariable; return changed; } } @@ -54,6 +56,12 @@ public static GlobalMonitorSettings ReadSettings() return JsonSerializer.Deserialize(stream,EnvironmentWorkspaceStore.JsonOptions) ?? new(); } public bool IsEnabled=>ReadSettings().Enabled; + public static EnvironmentWorkspace EffectiveOptions(EnvironmentWorkspace state) + { + var global=ReadSettings(); + if(global.Enabled) {state.AutoArrangeUserPath&=state.MonitoringEnabled;global.ApplyMachineOptions(state);} + return state; + } public void Configure(GlobalMonitorSettings settings) { if(!IsAllUsers) throw new IOException(L.Get("全局监控仅支持为所有用户安装。")); @@ -61,20 +69,22 @@ public void Configure(GlobalMonitorSettings settings) var start=new ProcessStartInfo(layout.BackgroundExecutable) {UseShellExecute=true,Verb="runas",WindowStyle=ProcessWindowStyle.Hidden}; start.ArgumentList.Add("--configure-global-monitor"); foreach(var value in new[]{settings.Enabled,settings.ProvideNonAdministratorOrigins,settings.AutoArrangeMachinePath,settings.RemoveMachinePathDuplicates,settings.MoveExternalMachinePathEntries,settings.IgnoreSystemEditorMachineChanges,settings.IgnorePowerToysMachineChanges}) start.ArgumentList.Add(value.ToString()); + ExternalPathOrganizer.ValidateTarget(settings.ExternalMachinePathVariable);start.ArgumentList.Add(settings.ExternalMachinePathVariable.Trim()); using var process=Process.Start(start) ?? throw new IOException(L.Get("无法配置全局监控服务。")); if(!process.WaitForExit(60000) || process.ExitCode!=0) throw new IOException(L.Get("无法配置全局监控服务。")); } private string ProtectedDirectory=>Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),"PathMan Monitor"); - public void ConfigureElevated(bool enabled,bool provideOrigins,bool organize,bool? removeDuplicates=null,bool? moveEntries=null,bool? ignoreSystemEditor=null,bool? ignorePowerToys=null) + public void ConfigureElevated(bool enabled,bool provideOrigins,bool organize,bool? removeDuplicates=null,bool? moveEntries=null,bool? ignoreSystemEditor=null,bool? ignorePowerToys=null,string? targetVariable=null) { DemandAdministrator(); using var mutex=WindowsUserContext.WorkspaceMutex(@"Global\PathMan.GlobalMonitor.Configuration"); try {if(!mutex.WaitOne(60000)) throw new IOException("Global configuration is busy.");}catch(AbandonedMutexException) { } - try {ConfigureLocked(enabled,provideOrigins,organize,removeDuplicates,moveEntries,ignoreSystemEditor,ignorePowerToys);}finally {mutex.ReleaseMutex();} + try {ConfigureLocked(enabled,provideOrigins,organize,removeDuplicates,moveEntries,ignoreSystemEditor,ignorePowerToys,targetVariable);}finally {mutex.ReleaseMutex();} } - private void ConfigureLocked(bool enabled,bool provideOrigins,bool organize,bool? removeDuplicates,bool? moveEntries,bool? ignoreSystemEditor,bool? ignorePowerToys) + private void ConfigureLocked(bool enabled,bool provideOrigins,bool organize,bool? removeDuplicates,bool? moveEntries,bool? ignoreSystemEditor,bool? ignorePowerToys,string? targetVariable) { if(!IsAllUsers) throw new IOException(L.Get("全局监控仅支持为所有用户安装。")); + if(targetVariable is not null) ExternalPathOrganizer.ValidateTarget(targetVariable); using var source=enabled ? PrivilegedServiceSource.Open(layout) : null; source?.ValidateDestination(ProtectedDirectory); OriginTrackingTask.SecureDirectory(Path.GetDirectoryName(DataDirectory)!); @@ -85,7 +95,8 @@ private void ConfigureLocked(bool enabled,bool provideOrigins,bool organize,bool if(previous.Enabled && !previous.InstallationDirectory.Equals(layout.InstallationDirectory,StringComparison.OrdinalIgnoreCase)) throw new IOException("Another installation owns global monitoring."); var updated=previous with {Enabled=enabled,ProvideNonAdministratorOrigins=provideOrigins,AutoArrangeMachinePath=organize,InstallationDirectory=layout.InstallationDirectory, RemoveMachinePathDuplicates=removeDuplicates ?? previous.RemoveMachinePathDuplicates,MoveExternalMachinePathEntries=moveEntries ?? previous.MoveExternalMachinePathEntries, - IgnoreSystemEditorMachineChanges=ignoreSystemEditor ?? previous.IgnoreSystemEditorMachineChanges,IgnorePowerToysMachineChanges=ignorePowerToys ?? previous.IgnorePowerToysMachineChanges}; + IgnoreSystemEditorMachineChanges=ignoreSystemEditor ?? previous.IgnoreSystemEditorMachineChanges,IgnorePowerToysMachineChanges=ignorePowerToys ?? previous.IgnorePowerToysMachineChanges, + ExternalMachinePathVariable=targetVariable?.Trim() ?? previous.ExternalMachinePathVariable}; if(enabled) { var executable=Path.Combine(ProtectedDirectory,"PathMan.Service.exe"); diff --git a/src/Pathman.Core/Localization/Strings.resx b/src/Pathman.Core/Localization/Strings.resx index 2e4ed01..3d0168e 100644 --- a/src/Pathman.Core/Localization/Strings.resx +++ b/src/Pathman.Core/Localization/Strings.resx @@ -409,15 +409,6 @@ Keep the shim directory only as the last user PATH entry so aliases do not shadow other programs. - - Group new external environment variables under “External variables” - - - Group new external user variables under “External variables” - - - Group new external system variables under “External variables” - Move externally added PATH entries to EXTERNAL_PATH @@ -1226,9 +1217,6 @@ PathMan v{0} is available! Click to view what's new Enable command-line version switching -Software path references -Automatic PATH organization is enabled, but PATH does not reference SOFTWARE_PATH or a numbered SOFTWARE path variable. -Add a standalone reference to the software path variable in PATH for the same scope, such as %SOFTWARE_PATH%. Command-line version switching is disabled. Enable it on the Version management page in PathMan. Go to version management This feature adds the command-line directory to PATH @@ -1245,4 +1233,24 @@ The global service requires an administrator-protected local installation directory. Reinstall PathMan in a secure directory. Cannot query the global monitoring service status. Global monitoring is enabled, but its service is not running. Ask an administrator to check it or enable global settings again. + Choose a valid path variable other than PATH or a built-in system variable. + Change-origin tracking is authorized + Administrator authorization is required to enable change-origin tracking + System-variable writes are authorized for this run + Administrator authorization is required to organize the system PATH + Organized automatically by the global service + Organize the system PATH now + Organization target references + The organization target {0} contains paths, but PATH does not reference it. + Add %{0}% to PATH in the corresponding scope. + Classify new external environment variables in + Move externally added PATH entries to + User-variable category + System-variable category + User PATH destination + System PATH destination + Global system PATH destination + Classify new external user variables in the selected category + Classify new external system variables in the selected category + category diff --git a/src/Pathman.Core/Localization/Strings.zh-CN.resx b/src/Pathman.Core/Localization/Strings.zh-CN.resx index 4e408ca..5601dd5 100644 --- a/src/Pathman.Core/Localization/Strings.zh-CN.resx +++ b/src/Pathman.Core/Localization/Strings.zh-CN.resx @@ -409,15 +409,6 @@ 将垫片目录只保留在用户 Path 的最后一行,避免别名遮蔽其他程序。 - - 将外部新环境变量归入‘外部变量’分类 - - - 将外部新用户环境变量归入‘外部变量’分类 - - - 将外部新系统环境变量归入‘外部变量’分类 - 将外部添加的PATH项移至EXTERNAL_PATH @@ -1226,9 +1217,6 @@ PathMan v{0} 已发布! 点击查看新版本内容 启用命令行版本切换 -软件路径引用 -已启用自动整理,但 PATH 未引用 SOFTWARE_PATH 或 SOFTWARE数字_PATH。 -在相应作用域的 PATH 中添加软件路径变量的纯引用,例如 %SOFTWARE_PATH%。 命令行版本切换未启用。请在 PathMan 的版本管理页面启用“启用命令行版本切换”。 前往版本管理 此功能将在PATH中添加命令行目录 @@ -1245,4 +1233,24 @@ 全局服务只能从管理员保护的本地安装目录部署,请重新安装到安全目录。 无法查询全局监控服务状态。 全局监控已启用,但服务未运行。请由管理员检查或重新启用全局设置。 + 请选择有效的路径变量,不能使用 PATH 或系统内置变量。 + 已获得修改来源识别授权 + 启用时需要管理员授权,仅用于识别修改来源 + 本次运行已获得系统变量写入授权 + 整理系统 PATH 前需要管理员授权 + 由全局服务自动整理 + 立即整理系统 PATH + 整理目标路径引用 + 整理目标 {0} 中已有路径,但 PATH 尚未引用它。 + 在相应作用域的 PATH 中添加 %{0}%。 + 将外部新环境变量归入 + 将外部添加的PATH项移至 + 用户变量归类目标 + 系统变量归类目标 + 用户PATH迁移目标 + 系统PATH迁移目标 + 全局系统PATH迁移目标 + 将外部新用户环境变量归入所选分类 + 将外部新系统环境变量归入所选分类 + 分类 diff --git a/src/Pathman.Core/Versions/CommandLinePathSetting.cs b/src/Pathman.Core/Versions/CommandLinePathSetting.cs index daa2a6e..d8a679f 100644 --- a/src/Pathman.Core/Versions/CommandLinePathSetting.cs +++ b/src/Pathman.Core/Versions/CommandLinePathSetting.cs @@ -12,13 +12,7 @@ public static bool IsSoftwareVariable(string name) if(!name.StartsWith("SOFTWARE",StringComparison.OrdinalIgnoreCase) || !name.EndsWith("_PATH",StringComparison.OrdinalIgnoreCase)) return false; return name.AsSpan(8,name.Length-13).IndexOfAnyExceptInRange('0','9')<0; } - public static string SoftwareVariable(IReadOnlyList variables,VariableScope scope) - { - var path=variables.FirstOrDefault(item=>item.Scope==scope && IsPath(item.Name)); - return path?.Entries.Select(EnvironmentPathResolver.PureReference).FirstOrDefault(name=>name is not null && IsSoftwareVariable(name)) - ?? variables.Where(item=>item.Scope==scope && IsSoftwareVariable(item.Name)).OrderBy(item=>item.Name.Length).ThenBy(item=>item.Name,StringComparer.OrdinalIgnoreCase).FirstOrDefault()?.Name - ?? "SOFTWARE_PATH"; - } + public static string TargetVariable(EnvironmentWorkspace state,VariableScope scope)=>ExternalPathOrganizer.GroupName(scope,state); public CommandLinePathPlan Plan(bool enabled,EnvironmentWorkspace state,IReadOnlyList variables,IReadOnlyDictionary? runtime=null) { if(scope is not (VariableScope.User or VariableScope.Machine)) throw new ArgumentOutOfRangeException(nameof(scope)); @@ -30,7 +24,7 @@ bool Matches(string entry)=>resolver.Expand(entry,scope) is {IsResolved:true,Pat bool Covers(string entry)=>resolver.Expand(entry,scope) is {IsResolved:true} value && value.Paths.Any(item=> string.Equals(EnvironmentPathResolver.Normalize(item),target,StringComparison.OrdinalIgnoreCase)); var sources=(path is null ? [] : resolver.ReferencedVariables(path.RawValue,scope).Prepend(path)) - .Concat(variables.Where(item=>item.Scope==scope && IsSoftwareVariable(item.Name))).Where(item=>item.Scope==scope) + .Concat(variables.Where(item=>item.Scope==scope && (IsSoftwareVariable(item.Name) || item.Name.Equals(TargetVariable(state,scope),StringComparison.OrdinalIgnoreCase)))).Where(item=>item.Scope==scope) .DistinctBy(item=>item.Name,StringComparer.OrdinalIgnoreCase).ToArray(); var mutations=new List(); void Set(EnvironmentVariableInfo? existing,string name,string value) @@ -53,12 +47,12 @@ void Set(EnvironmentVariableInfo? existing,string name,string value) // A nontrivial reference already exposing bin is left intact. var direct=path?.Entries.Where(entry=>EnvironmentPathResolver.PureReference(entry) is null && Matches(entry)).ToArray() ?? []; if(path?.Entries.Any(Covers)==true && direct.Length==0) return new([],[]); - var name=SoftwareVariable(variables,scope); + var name=TargetVariable(state,scope); var software=variables.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals(name,StringComparison.OrdinalIgnoreCase)); if(software?.Entries.Any(Covers)!=true) Set(software,name,Append(software?.RawValue,layout.BinDirectory)); - if(direct.Length>0) Set(path,path!.Name,string.Join(';',path.Entries.Where(entry=>!direct.Contains(entry,StringComparer.Ordinal)))); - // Missing SOFTWARE*_PATH references are reported by health checks; - // this mode never inserts a new reference into PATH implicitly. + var entries=path?.Entries.Where(entry=>!direct.Contains(entry,StringComparer.Ordinal)).ToList() ?? []; + if(!entries.Any(entry=>EnvironmentPathResolver.PureReference(entry)?.Equals(name,StringComparison.OrdinalIgnoreCase)==true)) entries.Add($"%{name}%"); + Set(path,path?.Name ?? "Path",string.Join(';',entries)); } else if(path?.Entries.Any(Covers)!=true) { diff --git a/src/Pathman.Gui/EnvironmentEditHost.cs b/src/Pathman.Gui/EnvironmentEditHost.cs index ce132dc..0407e90 100644 --- a/src/Pathman.Gui/EnvironmentEditHost.cs +++ b/src/Pathman.Gui/EnvironmentEditHost.cs @@ -14,6 +14,20 @@ internal static class EnvironmentEditHost public static event EventHandler? AuthorizationChanged; public static bool IsAuthorized=>session?.IsAlive==true; public static bool IsAdministrator {get {using var identity=WindowsIdentity.GetCurrent();return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);}} + private static async Task EnsureSessionAsync() + { + if(session?.IsAlive==true) return; + session?.Dispose();session=null; + session=await ElevatedEnvironmentSession.StartAsync();AuthorizationChanged?.Invoke(null,EventArgs.Empty); + } + public static async Task EnsureAuthorizedAsync() + { + if(IsAdministrator) return; + await Gate.WaitAsync(); + try {await EnsureSessionAsync();} + catch(Win32Exception error) when(error.NativeErrorCode==1223) {throw new OperationCanceledException(L.Get("已取消管理员操作。"));} + finally {Gate.Release();} + } public static Task ApplyAsync(EnvironmentMutation mutation,EnvironmentWorkspaceStore store,string dataDirectory)=>ApplyBatchAsync([mutation],[],store,dataDirectory); public static async Task ApplyBatchAsync(IReadOnlyList mutations,IReadOnlyList preconditions,EnvironmentWorkspaceStore store,string dataDirectory) { @@ -31,20 +45,15 @@ await Task.Run(()=> { await Gate.WaitAsync(); try { - if(session is null || !session.IsAlive) - { - session?.Dispose();session=null; - session=await ElevatedEnvironmentSession.StartAsync(); - AuthorizationChanged?.Invoke(null,EventArgs.Empty); - } + await EnsureSessionAsync(); // The registry signal can arrive before the unelevated process records // the result. Pause observation briefly across that hand-off so an // application write is never mistaken for an external PATH addition. store.Update(state=>state.RegistryWriteInProgressUntil=DateTime.UtcNow.AddMinutes(2)); try { - try {await session.ApplyAsync(mutations,preconditions);} - catch(IOException error) when(error is not EnvironmentOperationException) {session.Dispose();session=null;AuthorizationChanged?.Invoke(null,EventArgs.Empty);throw;} + try {await session!.ApplyAsync(mutations,preconditions);} + catch(IOException error) when(error is not EnvironmentOperationException) {session?.Dispose();session=null;AuthorizationChanged?.Invoke(null,EventArgs.Empty);throw;} try {await Task.Run(()=>WindowsEnvironmentVariableEditor.RecordOwnChanges(store,mutations));} catch(Exception error) when(error is IOException or UnauthorizedAccessException) {throw new IOException(L.Get("系统变量已保存,但用户记录更新失败。请刷新确认。")+error.Message,error);} diff --git a/src/Pathman.Gui/EnvironmentEntry.cs b/src/Pathman.Gui/EnvironmentEntry.cs index 4c531a4..ca74843 100644 --- a/src/Pathman.Gui/EnvironmentEntry.cs +++ b/src/Pathman.Gui/EnvironmentEntry.cs @@ -3,10 +3,13 @@ namespace Pathman.Gui; // Each occurrence has its own identity, including duplicate and empty values. public sealed record EnvironmentReferenceLink(string Name, Pathman.Core.Models.VariableScope? Scope); public sealed class EnvironmentEntry(string value, bool isDraft = false, IReadOnlyList? references = null, - Pathman.Core.EnvironmentVariables.PathEditorRow? row = null, bool placeholder = false) + Pathman.Core.EnvironmentVariables.PathEditorRow? row = null, bool placeholder = false) : System.ComponentModel.INotifyPropertyChanged { - public string Value { get; } = value; - public bool IsDraft { get; } = isDraft; + public string Value { get; private set; } = value; + public bool IsDraft { get; private set; } = isDraft; + public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; + public void Update(Pathman.Core.EnvironmentVariables.PathEditorRow row,IReadOnlyList references,bool placeholder) + {Row=row;Value=row.Item.Value;IsDraft=row.Item.IsDraft;References=references;IsDragPlaceholder=placeholder;PropertyChanged?.Invoke(this,new(null));} public bool IsGhost=>Row?.IsGhost==true; public bool CanEdit=>!IsGhost; public bool IsReview=>Row?.IsReview==true; @@ -14,16 +17,16 @@ public sealed class EnvironmentEntry(string value, bool isDraft = false, IReadOn public bool IsExternalAdded=>Row?.Origin==Pathman.Core.EnvironmentVariables.EnvironmentEditOrigin.ExternalAdded; public bool IsExternalRemoved=>Row?.Origin==Pathman.Core.EnvironmentVariables.EnvironmentEditOrigin.ExternalRemoved; public bool IsUserChange=>Row?.Origin is Pathman.Core.EnvironmentVariables.EnvironmentEditOrigin.UserAdded or Pathman.Core.EnvironmentVariables.EnvironmentEditOrigin.UserRemoved; - public IReadOnlyList References { get; } = references ?? []; + public IReadOnlyList References { get; private set; } = references ?? []; public bool HasReferences => References.Count > 0; - public Pathman.Core.EnvironmentVariables.PathEditorRow? Row { get; } = row; + public Pathman.Core.EnvironmentVariables.PathEditorRow? Row { get; private set; } = row; public Guid ItemId => Row?.Item.Id ?? Guid.Empty; public string ViewKey => Row?.ViewKey ?? ""; public bool IsGroup => Row?.IsGroup == true; public bool IsExpanded => Row?.IsExpanded == true; public bool CanExpand => IsGroup && Row?.IsCycle != true; public string ExpandActionName => (IsExpanded ? L.Get("折叠 ") : L.Get("展开 "))+Value; - public bool IsDragPlaceholder {get;}=placeholder; + public bool IsDragPlaceholder {get;private set;}=placeholder; public System.Windows.Thickness Indent => new((Row?.Depth ?? 0)*16,0,0,0); public string GroupToolTip => Row?.IsCycle==true ? L.Get("引用回环或层级过深,已停止展开") : L.Get("展开或折叠引用的路径变量"); public string ReferenceToolTip => L.Get("转到:") + string.Join("、", References.Select(item => item.Name)); diff --git a/src/Pathman.Gui/MainWindow.xaml.cs b/src/Pathman.Gui/MainWindow.xaml.cs index 80c3605..3b2935e 100644 --- a/src/Pathman.Gui/MainWindow.xaml.cs +++ b/src/Pathman.Gui/MainWindow.xaml.cs @@ -26,6 +26,7 @@ public partial class MainWindow : Window private string healthSignature=""; private string monitorSignature=""; private bool monitorApplying; + private bool closingPromptActive; private readonly HashSet attemptedMonitorPlans=[]; private EnvironmentSnapshotWatcher? foregroundMonitor; private IReadOnlyList? pendingRegistrySnapshot; @@ -174,6 +175,8 @@ private async Task ScanHealthAsync() var result=await Task.Run(()=> { var state=store.Read(); + var global=GlobalMonitoring.ReadSettings(); + if(global.Enabled) {state.AutoArrangeUserPath&=state.MonitoringEnabled;global.ApplyMachineOptions(state);} return (Report:EnvironmentHealth.Analyze(EnvironmentMonitor.ReadAll(),shimsDirectory:layout.ShimsDirectory,workspace:state),Ignored:state.IgnoredHealthIssues); }); health.Update(result.Report,result.Ignored); @@ -221,10 +224,10 @@ private async void CheckWorkspace(object? sender,EventArgs e) private void ShowMonitorNotification(EnvironmentWorkspace state) { var pending=!new GlobalMonitoring(layout).IsEnabled && state.MonitoringEnabled && state.AutoArrangeMachinePath && state.PendingPathOrganizations.Any(item=>item.Scope==Pathman.Core.Models.VariableScope.Machine); - var signature=$"{pending}:{state.MonitorMessageTime:O}:{state.MonitorMessage}:{state.OriginTrackingMessage}"; + var signature=$"{pending}:{EnvironmentEditHost.IsAuthorized}:{EnvironmentEditHost.IsAdministrator}:{state.MonitorMessageTime:O}:{state.MonitorMessage}:{state.OriginTrackingMessage}"; if(signature==monitorSignature) return;monitorSignature=signature; if(state.OriginTrackingMessage is {} tracking) notifications.Show(new("monitor",L.Get("修改来源识别"),tracking+"\n"+Osc8Text.Link(L.Get("前往变量监控"),"apppage:monitor"),NotificationColor.Yellow)); - else if(pending) notifications.Show(new("monitor",L.Get("系统 PATH 等待整理"),L.Get("外部新增项需要管理员授权。\n")+Osc8Text.Link(L.Get("前往变量监控"),"apppage:monitor"),NotificationColor.Yellow)); + else if(pending && !EnvironmentEditHost.IsAuthorized && !EnvironmentEditHost.IsAdministrator) notifications.Show(new("monitor",L.Get("系统 PATH 等待整理"),L.Get("外部新增项需要管理员授权。\n")+Osc8Text.Link(L.Get("前往变量监控"),"apppage:monitor"),NotificationColor.Yellow)); else if(state.MonitorMessage is {} message) notifications.Show(new("monitor",L.Get("变量监控"),message+"\n"+Osc8Text.Link(L.Get("查看变量监控"),"apppage:monitor"),NotificationColor.Yellow)); else notifications.Dismiss("monitor"); } @@ -249,6 +252,14 @@ private async Task ApplyPendingMonitoringAsync(bool automatic=false) } await ScanHealthAsync(); } + catch(EnvironmentConflictException) + { + // An external write or the user's own health repair superseded this + // preview. Rebuild it without revoking authorization or showing a stale error. + try {await Task.Run(()=>new EnvironmentMonitor(store).RefreshPlans());} + catch(Exception error) when(error is System.IO.IOException or UnauthorizedAccessException or System.Security.SecurityException or System.Text.Json.JsonException) + {notifications.Show(new("monitor",L.Get("变量监控"),error.Message,NotificationColor.Yellow));} + } catch(Exception error) when(error is System.IO.IOException or UnauthorizedAccessException or System.Security.SecurityException or ArgumentException or OperationCanceledException) { if(error is not OperationCanceledException) @@ -268,7 +279,12 @@ private async Task ApplyPendingMonitoringAsync(bool automatic=false) } private async void OnClosing(object? sender,CancelEventArgs e) { - if(healthWorking || monitorApplying || versions.IsBusy) {e.Cancel=true;return;} + if(closingApproved) return; + if(closingPromptActive) {e.Cancel=true;return;} + closingPromptActive=true; + try + { + if(healthWorking || monitorApplying || versions.IsBusy || variables.IsBusy && !variables.HasInlineEditor) {e.Cancel=true;return;} if(closingApproved) return; if(versions.HasInlineEditor) {e.Cancel=true;if(await versions.ConfirmLeaveAsync()) {closingApproved=true;Close();}return;} @@ -281,5 +297,7 @@ private async void OnClosing(object? sender,CancelEventArgs e) if(await variables.ConfirmLeaveAsync()) { closingApproved=true; Close(); } } else if(!variables.ConfirmLeave()) e.Cancel=true; + } + finally {closingPromptActive=false;} } } diff --git a/src/Pathman.Gui/Views/MonitorView.Targets.cs b/src/Pathman.Gui/Views/MonitorView.Targets.cs new file mode 100644 index 0000000..40cf3e2 --- /dev/null +++ b/src/Pathman.Gui/Views/MonitorView.Targets.cs @@ -0,0 +1,80 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using Pathman.Core; +using Pathman.Core.EnvironmentVariables; +using Pathman.Core.Models; + +namespace Pathman.Gui.Views; + +public partial class MonitorView +{ + private string? targetError; + private void RefreshTargets(EnvironmentWorkspace state,GlobalMonitorSettings global) + { + void Groups(ComboBox choice,VariableScope scope) + { + if(choice.IsKeyboardFocusWithin || choice.IsDropDownOpen) return; + var items=state.Groups.Where(group=>group.Scope is null || group.Scope==scope).ToArray(); + var selected=items.FirstOrDefault(group=>group.Id==state.ExternalGroupId(scope)) ?? items.FirstOrDefault(group=>group.Rule=="external"); + choice.ItemsSource=items;choice.SelectedValue=selected?.Id; + } + Groups(UserExternalGroup,VariableScope.User);Groups(MachineExternalGroup,VariableScope.Machine); + var variables=EnvironmentMonitor.ReadAll(); + void Targets(ComboBox choice,VariableScope scope,string selected) + { + if(choice.IsKeyboardFocusWithin || choice.IsDropDownOpen) return; + choice.ItemsSource=variables.Where(item=>item.Scope==scope && ExternalPathOrganizer.IsValidTarget(item.Name)).Select(item=>item.Name) + .Append(ExternalPathOrganizer.GroupName(scope)).Append(selected).Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).ToArray(); + choice.Text=selected; + } + Targets(UserPathTarget,VariableScope.User,ExternalPathOrganizer.GroupName(VariableScope.User,state)); + Targets(MachinePathTarget,VariableScope.Machine,ExternalPathOrganizer.GroupName(VariableScope.Machine,state)); + Targets(GlobalPathTarget,VariableScope.Machine,global.ExternalMachinePathVariable); + UserPathTarget.IsEnabled=state.MoveExternalUserPathEntries; + MachinePathTarget.IsEnabled=state.MoveExternalMachinePathEntries; + GlobalPathTarget.IsEnabled=global.MoveExternalMachinePathEntries; + } + private void GroupTargetChanged(object sender,SelectionChangedEventArgs e) + { + if(updating || busy || sender is not ComboBox {SelectedValue:string id} choice) return; + try {store.Update(state=> + { + if(choice==UserExternalGroup) state.ExternalUserGroupId=id;else state.ExternalMachineGroupId=id; + });} + catch(Exception error) when(Expected(error)) {ShowError(error);return;} + OptionsChanged?.Invoke(this,EventArgs.Empty); + } + private void PathTargetChanged(object? sender,EventArgs e) + { + if(updating || busy || sender is not ComboBox choice) return; + var target=choice.Text.Trim(); + var state=store.Read();var global=GlobalMonitoring.ReadSettings(); + var previous=choice==UserPathTarget ? ExternalPathOrganizer.GroupName(VariableScope.User,state) + : choice==MachinePathTarget ? ExternalPathOrganizer.GroupName(VariableScope.Machine,state) : global.ExternalMachinePathVariable; + if(target.Equals(previous,StringComparison.OrdinalIgnoreCase)) {targetError=null;RefreshNotice(state);return;} + try {ExternalPathOrganizer.ValidateTarget(target);} + catch(ArgumentException error) {targetError=error.Message;ShowError(error);return;} + targetError=null; + if(choice==GlobalPathTarget) {ChangeGlobalOptions(choice,new RoutedEventArgs());return;} + busy=true; + try + { + store.Update(saved=> + { + var scope=choice==UserPathTarget ? VariableScope.User : VariableScope.Machine; + if(scope==VariableScope.User) saved.ExternalUserPathVariable=target;else saved.ExternalMachinePathVariable=target; + saved.PendingPathOrganizations.RemoveAll(plan=>plan.Scope==scope); + }); + busy=false;Update(store.Read());OptionsChanged?.Invoke(this,EventArgs.Empty); + } + catch(Exception error) when(Expected(error)) {ShowError(error);} + finally {busy=false;} + } + private void PathTargetLostFocus(object sender,KeyboardFocusChangedEventArgs e) + { + if(sender is ComboBox {IsDropDownOpen:false} choice && !choice.IsKeyboardFocusWithin) PathTargetChanged(choice,EventArgs.Empty); + } + private void PathTargetKeyDown(object sender,KeyEventArgs e) + {if(e.Key==Key.Enter) {e.Handled=true;PathTargetChanged(sender,EventArgs.Empty);}} +} diff --git a/src/Pathman.Gui/Views/MonitorView.xaml b/src/Pathman.Gui/Views/MonitorView.xaml index 856548b..9200712 100644 --- a/src/Pathman.Gui/Views/MonitorView.xaml +++ b/src/Pathman.Gui/Views/MonitorView.xaml @@ -11,21 +11,21 @@ - + - - + + - + @@ -40,18 +40,18 @@ - - + + - + - + @@ -78,7 +78,7 @@ - + diff --git a/src/Pathman.Gui/Views/MonitorView.xaml.cs b/src/Pathman.Gui/Views/MonitorView.xaml.cs index 05d4f4f..908132f 100644 --- a/src/Pathman.Gui/Views/MonitorView.xaml.cs +++ b/src/Pathman.Gui/Views/MonitorView.xaml.cs @@ -47,7 +47,12 @@ private void RefreshProcessState() GlobalRemoveDuplicates.IsChecked=settings.RemoveMachinePathDuplicates;GlobalMoveEntries.IsChecked=settings.MoveExternalMachinePathEntries; GlobalIgnoreSystemEditor.IsChecked=settings.IgnoreSystemEditorMachineChanges;GlobalIgnorePowerToys.IsChecked=settings.IgnorePowerToysMachineChanges; GlobalArrangeOptions.IsEnabled=unified && settings.AutoArrangeMachinePath; + RefreshTargets(state,settings); UserEditorExclusions.IsEnabled=MachineEditorExclusions.IsEnabled=state.TrackChangeOrigins; + var authorized=EnvironmentEditHost.IsAuthorized || EnvironmentEditHost.IsAdministrator; + SourceAuthorizationHint.Text=L.Get(state.OriginTrackingReady ? "已获得修改来源识别授权" : "启用时需要管理员授权,仅用于识别修改来源"); + MachineAuthorizationHint.Text=L.Get(unified ? "由全局服务自动整理" : authorized ? "本次运行已获得系统变量写入授权" : "整理系统 PATH 前需要管理员授权"); + ApplyPending.Content=L.Get(authorized ? "立即整理系统 PATH" : "授权并整理系统 PATH"); RefreshNotice(state); } finally {updating=false;} @@ -86,7 +91,8 @@ public void Update(EnvironmentWorkspace state) private void RefreshNotice(EnvironmentWorkspace state) { var pending=!new GlobalMonitoring(layout).IsEnabled && state.MonitoringEnabled && state.AutoArrangeMachinePath && state.PendingPathOrganizations.Any(item=>item.Scope==VariableScope.Machine); - var message=GlobalMonitoring.GetUnavailableMessage() ?? state.OriginTrackingMessage ?? (pending ? L.Get("系统 PATH 有待整理的外部新增项,需要管理员授权。") : state.MonitorMessage); + var authorized=EnvironmentEditHost.IsAuthorized || EnvironmentEditHost.IsAdministrator; + var message=targetError ?? GlobalMonitoring.GetUnavailableMessage() ?? state.OriginTrackingMessage ?? (pending && !authorized ? L.Get("系统 PATH 有待整理的外部新增项,需要管理员授权。") : state.MonitorMessage); MonitorStatus.Text=message ?? "";MonitorNotice.Visibility=string.IsNullOrEmpty(message) ? Visibility.Collapsed : Visibility.Visible; } private void CloseHistory(object sender,RoutedEventArgs e) {HistoryPage.Visibility=Visibility.Collapsed;SettingsPage.Visibility=Visibility.Visible;} @@ -127,6 +133,8 @@ private async void ChangeOptions(object sender,RoutedEventArgs e) busy=true;SettingsPanel.IsEnabled=false; try { + if(ReferenceEquals(sender,AutoArrangeMachine) && AutoArrangeMachine.IsChecked==true && !new GlobalMonitoring(layout).IsEnabled) + await EnvironmentEditHost.EnsureAuthorizedAsync(); if(ReferenceEquals(sender,TrackOrigins) && TrackOrigins.IsChecked==true) if(!new GlobalMonitoring(layout).IsEnabled) await Task.Run(()=>new OriginTrackingTask(layout).EnsureRegistered()); store.Update(state=> @@ -153,7 +161,8 @@ private async void ChangeGlobalOptions(object sender,RoutedEventArgs e) var settings=new GlobalMonitorSettings(GlobalEnabled.IsChecked==true,GlobalOrigins.IsChecked==true,GlobalAutoArrange.IsChecked==true) { RemoveMachinePathDuplicates=GlobalRemoveDuplicates.IsChecked==true,MoveExternalMachinePathEntries=GlobalMoveEntries.IsChecked==true, - IgnoreSystemEditorMachineChanges=GlobalIgnoreSystemEditor.IsChecked==true,IgnorePowerToysMachineChanges=GlobalIgnorePowerToys.IsChecked==true + IgnoreSystemEditorMachineChanges=GlobalIgnoreSystemEditor.IsChecked==true,IgnorePowerToysMachineChanges=GlobalIgnorePowerToys.IsChecked==true, + ExternalMachinePathVariable=GlobalPathTarget.Text.Trim() }; busy=true;SettingsPanel.IsEnabled=false; try {await Task.Run(()=>new GlobalMonitoring(layout).Configure(settings));busy=false;Update(store.Read());OptionsChanged?.Invoke(this,EventArgs.Empty);} @@ -165,5 +174,5 @@ private static bool IsAdministratorAccount() return WindowsUserContext.IsAdministratorAccount(); } public void SetApplying(bool value) {busy=value;SettingsPanel.IsEnabled=!value;} - private static bool Expected(Exception error)=>error is IOException or UnauthorizedAccessException or System.Security.SecurityException or System.ComponentModel.Win32Exception or System.Text.Json.JsonException; + private static bool Expected(Exception error)=>error is IOException or UnauthorizedAccessException or System.Security.SecurityException or System.ComponentModel.Win32Exception or System.Text.Json.JsonException or OperationCanceledException or ArgumentException; } diff --git a/src/Pathman.Gui/Views/VariablesView.Cleanup.cs b/src/Pathman.Gui/Views/VariablesView.Cleanup.cs index 4d22dfc..fc2ccfd 100644 --- a/src/Pathman.Gui/Views/VariablesView.Cleanup.cs +++ b/src/Pathman.Gui/Views/VariablesView.Cleanup.cs @@ -47,7 +47,7 @@ private async void PreviewCleanup(object sender, RoutedEventArgs e) if (busy || !await ConfirmLeaveAsync()) return; StatusText.Text=""; await ReloadAsync(); - cleanupPlan=EnvironmentPathCleanup.Analyze(allVariables, RuntimeValues()); + cleanupPlan=EnvironmentPathCleanup.Analyze(allVariables, RuntimeValues(),PlanningSettings()); ShowCleanupPlan(); CleanupStatus.Text=""; EditorPanel.Visibility=Visibility.Collapsed; CleanupPanel.Visibility=Visibility.Visible; } @@ -95,6 +95,6 @@ private async void ApplyCleanup(object sender, RoutedEventArgs e) { status=(applied.Count>0 ? L.Format("已清理{0};其余未应用。",string.Join("、",applied)) : L.Get("未应用清理。")) + error.Message; } finally { busy=false; CleanupButton.IsEnabled=true; } await ReloadAsync(); - cleanupPlan=EnvironmentPathCleanup.Analyze(allVariables,RuntimeValues()); ShowCleanupPlan(); CleanupStatus.Text=status; + cleanupPlan=EnvironmentPathCleanup.Analyze(allVariables,RuntimeValues(),PlanningSettings()); ShowCleanupPlan(); CleanupStatus.Text=status; } } diff --git a/src/Pathman.Gui/Views/VariablesView.Live.cs b/src/Pathman.Gui/Views/VariablesView.Live.cs index 2fcf8fe..2d61558 100644 --- a/src/Pathman.Gui/Views/VariablesView.Live.cs +++ b/src/Pathman.Gui/Views/VariablesView.Live.cs @@ -20,7 +20,7 @@ public async Task ObserveRegistryAsync(IReadOnlyListentry.ViewKey); + var desired=new List(); foreach(var row in session.VisibleRows(expandedPathRows)) { var links=EnvironmentPathResolver.ReferenceNames(row.Item.Value).Select(name=>new EnvironmentReferenceLink(name,resolver.Find(name,session.Root.Scope)?.Scope)).ToArray(); - entries.Add(new(row.Item.Value,row.Item.IsDraft,links,row,pathDragging && row.ViewKey==pathDragSource?.ViewKey)); + var placeholder=pathDragging && row.ViewKey==pathDragSource?.ViewKey; + if(existing.TryGetValue(row.ViewKey,out var entry)) {entry.Update(row,links,placeholder);desired.Add(entry);} + else desired.Add(new(row.Item.Value,row.Item.IsDraft,links,row,placeholder)); } + var retained=desired.ToHashSet(); + for(var index=entries.Count-1;index>=0;index--) if(!retained.Contains(entries[index])) entries.RemoveAt(index); + for(var index=0;indexitem.ViewKey==selectedKey) ?? entries.FirstOrDefault(item=>item.ItemId==selectedId); if(preserveSelection) foreach(var entry in entries.Where(item=>selectedKeys.Contains(item.ViewKey))) if(!ValuesList.SelectedItems.Contains(entry)) ValuesList.SelectedItems.Add(entry); - if(restoreFocus) ValuesList.Focus(); + if(restoreFocus && activeEditor?.IsEditing!=true) ValuesList.Focus(); updating=previousUpdating; dirty=session.HasChanges; UpdateActions(); diff --git a/src/Pathman.Gui/Views/VariablesView.xaml b/src/Pathman.Gui/Views/VariablesView.xaml index 0324208..b265934 100644 --- a/src/Pathman.Gui/Views/VariablesView.xaml +++ b/src/Pathman.Gui/Views/VariablesView.xaml @@ -117,7 +117,7 @@