diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8e4ef01..15d1cbd 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 -- --test-global-layout --test-creation - name: Build baseline installers if: ${{ !env.ACT }} run: ./build.ps1 -Arch $env:PATHMAN_ARCH -VersionName '0.0.0' -VersionNumber '0.0.0' -ReleaseType official @@ -134,6 +137,8 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: acceptance-${{ matrix.arch }} - path: artifacts/installer-acceptance/**/results.json + path: | + artifacts/installer-acceptance/**/results.json + artifacts/global-monitor/** if-no-files-found: ignore retention-days: 14 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..f74305c 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` 置顶,其后只列出同一作用域内“路径变量”分类中的变量。默认项在实际使用时创建,两个菜单底部均提供新建按钮,创建后自动选中。全局系统 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/scripts/test.ps1 b/scripts/test.ps1 index a250322..76b6c02 100644 --- a/scripts/test.ps1 +++ b/scripts/test.ps1 @@ -150,8 +150,14 @@ try { Assert ($missing.Code -eq 13) 'A missing manifest should fail clearly.' if($unified){$background.Start()}else{Assert-Rejected {$background.Start()}} Assert (!$background.IsRunning) 'Disabled background must remain stopped.' + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{$owner=[Pathman.Core.BackgroundProcess]::OwnerMutex($identity.User.Value,$layout.InstallationDirectory,[Diagnostics.Process]::GetCurrentProcess().SessionId)}finally{$identity.Dispose()} + $ownerAcquired=$false + try{try{$ownerAcquired=$owner.WaitOne(0)}catch [Threading.AbandonedMutexException]{$ownerAcquired=$true}} + finally{if($ownerAcquired){$owner.ReleaseMutex()};$owner.Dispose()} $duplicate = Invoke-Program (Join-Path $installation 'internal/PathMan.Service.exe') @('--data-root', $dataRoot) - Assert ($duplicate.Code -eq $(if($unified){0}else{5})) 'A direct launch must leave the personal background stopped when disabled or owned by the unified service.' + $expectedBackgroundExit=if($unified -or !$ownerAcquired){0}else{5} + Assert ($duplicate.Code -eq $expectedBackgroundExit) "A direct launch must leave this installation's background stopped when disabled or another monitor owns the session. Exit=$($duplicate.Code), expected=$expectedBackgroundExit. $($duplicate.Error)" $background.Stop() Assert (!$background.IsRunning) 'Background process did not stop.' if($unified){$background.Start()}else{Assert-Rejected {$background.Start()}} diff --git a/src/Pathman.Core/EnvironmentVariables/Defaults/groups.json b/src/Pathman.Core/EnvironmentVariables/Defaults/groups.json index 91c910a..4938e92 100644 --- a/src/Pathman.Core/EnvironmentVariables/Defaults/groups.json +++ b/src/Pathman.Core/EnvironmentVariables/Defaults/groups.json @@ -2,7 +2,6 @@ "groups": [ { "id": "paths", "name": "路径变量", "rule": "paths" }, { "id": "system", "name": "系统变量", "rule": "builtin", "collapsed": true }, - { "id": "other", "name": "其他变量", "rule": "fallback" }, - { "id": "external", "name": "外部变量", "rule": "external", "requiresMonitoring": true } + { "id": "other", "name": "其他变量", "rule": "fallback" } ] } 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..3414ee3 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); @@ -137,6 +147,7 @@ internal static bool Compare(EnvironmentWorkspace state, IReadOnlyList 200) state.Changes.RemoveRange(200, state.Changes.Count - 200); + state.MaterializeExternalGroup(); EnvironmentMonitorNotices.Reconcile(state); return true; } diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitorNotices.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitorNotices.cs index 7aa7555..b66d82e 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitorNotices.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitorNotices.cs @@ -1,10 +1,17 @@ using Pathman.Core.Localization; using Pathman.Core.Models; +using System.Text.RegularExpressions; namespace Pathman.Core.EnvironmentVariables; public static class EnvironmentMonitorNotices { + // Older versions persisted optimistic-concurrency failures after a health + // repair had already succeeded. These are transient previews, not durable + // monitoring failures; the current monitor replans them without a warning. + private static readonly Regex[] ObsoleteConflicts=new[]{"系统 PATH 未整理:","自动整理未完成:"}.SelectMany(Text.Translations) + .SelectMany(prefix=>Text.Translations("{0} 已被其他程序修改,请重新预览清理结果。").Select(format=> + new Regex("\\A"+Regex.Escape(prefix+format).Replace(Regex.Escape("{0}"),"[^\\0\\r\\n=]{1,255}")+"\\z",RegexOptions.IgnoreCase|RegexOptions.CultureInvariant|RegexOptions.NonBacktracking))).ToArray(); public const string UnknownOriginMessage="未能确认本次 PATH 修改来源,已保留外部更改,未自动整理。"; public static void ReportUnknown(EnvironmentWorkspace state,VariableScope scope,string? before,string? after) { @@ -18,6 +25,11 @@ public static bool Reconcile(EnvironmentWorkspace state) { var unknown=Text.IsTranslation(UnknownOriginMessage,state.MonitorMessage); var changed=false; + if(state.MonitorMessage is {} message && ObsoleteConflicts.Any(pattern=>pattern.IsMatch(message))) + {state.MonitorMessage=null;state.MonitorMessageTime=null;changed=true;} + 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..a26f3c2 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs @@ -53,6 +53,28 @@ 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) + { + var id=scope==VariableScope.User ? ExternalUserGroupId : ExternalMachineGroupId; + return Groups.Any(group=>group.Id==id && (group.Scope is null || group.Scope==scope)) ? id : MonitoringDestinations.ExternalGroupId; + } + internal bool MaterializeExternalGroup() + { + if(!MonitoringEnabled || Groups.Any(group=>group.Id==MonitoringDestinations.ExternalGroupId)) return false; + foreach(var scope in new[]{VariableScope.User,VariableScope.Machine}) + { + if(!ShouldGroupExternalVariables(scope) || ExternalGroupId(scope)!=MonitoringDestinations.ExternalGroupId) continue; + var visible=VisibleGroups(scope).Select(group=>group.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + if(!ExternalVariables.Any(key=>key.StartsWith(scope+":",StringComparison.OrdinalIgnoreCase) && + (!Assignments.TryGetValue(key,out var assigned) || !visible.Contains(assigned)))) continue; + Groups.Add(MonitoringDestinations.DefaultExternalGroup());return true; + } + return false; + } public bool AutoArrangeUserPath {get;set;} public bool AutoArrangeMachinePath {get;set;} [JsonIgnore] public bool AnyPathOrganizationEnabled=>AutoArrangeUserPath || AutoArrangeMachinePath; @@ -216,16 +238,16 @@ private T Locked(Func action) } public EnvironmentWorkspace Read() => Locked(()=> { - var state=Load();if(EnvironmentMonitorNotices.Reconcile(state)) Save(state);return state; + var state=Load();var changed=state.MaterializeExternalGroup();changed|=EnvironmentMonitorNotices.Reconcile(state);if(changed) Save(state);return state; }); public EnvironmentWorkspace ReadSnapshot()=>Load(); public void Update(Action change) => Locked(() => { - var state = Load(); change(state); EnvironmentMonitorNotices.Reconcile(state); Save(state); return true; + var state = Load(); change(state); state.MaterializeExternalGroup();EnvironmentMonitorNotices.Reconcile(state); Save(state); return true; }); internal void Inspect(Func change) => Locked(() => { - var state = Load(); var reconciled=EnvironmentMonitorNotices.Reconcile(state); if (change(state) || reconciled) Save(state); return true; + var state = Load(); var reconciled=EnvironmentMonitorNotices.Reconcile(state);var changed=change(state);changed|=state.MaterializeExternalGroup();if(changed || reconciled) Save(state);return true; }); public void InspectForService(Func change)=>Inspect(change); private void Save(EnvironmentWorkspace state) 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/PathOrganizationReferenceHealthCheck.cs b/src/Pathman.Core/EnvironmentVariables/HealthChecks/PathOrganizationReferenceHealthCheck.cs new file mode 100644 index 0000000..60498a2 --- /dev/null +++ b/src/Pathman.Core/EnvironmentVariables/HealthChecks/PathOrganizationReferenceHealthCheck.cs @@ -0,0 +1,26 @@ +using Pathman.Core.Models; + +namespace Pathman.Core.EnvironmentVariables; + +public sealed class PathOrganizationReferenceHealthCheck : IEnvironmentHealthCheck +{ + public string Id=>"path-organization-reference"; + 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) || !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?.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.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/HealthChecks/SoftwarePathReferenceHealthCheck.cs b/src/Pathman.Core/EnvironmentVariables/HealthChecks/SoftwarePathReferenceHealthCheck.cs deleted file mode 100644 index 8a4c503..0000000 --- a/src/Pathman.Core/EnvironmentVariables/HealthChecks/SoftwarePathReferenceHealthCheck.cs +++ /dev/null @@ -1,24 +0,0 @@ -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 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; - 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))) - .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%。")}; - } - } -} diff --git a/src/Pathman.Core/EnvironmentVariables/MonitoringDestinations.cs b/src/Pathman.Core/EnvironmentVariables/MonitoringDestinations.cs new file mode 100644 index 0000000..5c4d87f --- /dev/null +++ b/src/Pathman.Core/EnvironmentVariables/MonitoringDestinations.cs @@ -0,0 +1,24 @@ +using Pathman.Core.Models; + +namespace Pathman.Core.EnvironmentVariables; + +public static class MonitoringDestinations +{ + public const string ExternalGroupId="external"; + public static VariableGroupDefinition DefaultExternalGroup()=>new() {Id=ExternalGroupId,Name="外部变量",Rule="external",RequiresMonitoring=true}; + public static IReadOnlyList Categories(EnvironmentWorkspace state,VariableScope scope) + { + var groups=state.Groups.Where(group=>group.Scope is null || group.Scope==scope).ToArray(); + var first=groups.FirstOrDefault(group=>group.Id==ExternalGroupId) ?? DefaultExternalGroup(); + return new[]{first}.Concat(state.VisibleGroups(scope)).Concat(groups).DistinctBy(group=>group.Id,StringComparer.OrdinalIgnoreCase).ToArray(); + } + public static IReadOnlyList PathVariables(EnvironmentWorkspace state,IReadOnlyList variables, + VariableScope scope,IReadOnlyDictionary? runtime=null) + { + var first=ExternalPathOrganizer.GroupName(scope); + var groups=state.VisibleGroups(scope).Where(group=>group.Rule=="paths").Select(group=>group.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + return new[]{first}.Concat(variables.Where(variable=>variable.Scope==scope && ExternalPathOrganizer.IsValidTarget(variable.Name) && + groups.Contains(EnvironmentVariableClassifier.Classify(variable,variables,state,runtime))).Select(variable=>variable.Name) + .Where(name=>!name.Equals(first,StringComparison.OrdinalIgnoreCase)).Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase)).ToArray(); + } +} 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..33811d2 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"); @@ -126,15 +137,44 @@ public void Remove() } public static void Stop() { - var result=Sc("stop",ServiceName); - if(result is not (0 or 1060 or 1062)) throw new Win32Exception(result); - for(var retry=0;retry<150;retry++) + var manager=OpenSCManager(null,null,1); + if(manager==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + try { - var status=ScOutput("query",ServiceName); - if(status.Code==1060 || status.Output.Contains("STOPPED",StringComparison.Ordinal)) return; - Thread.Sleep(100); + var service=OpenService(manager,ServiceName,4); + if(service==IntPtr.Zero) + { + var error=Marshal.GetLastWin32Error(); + if(error==1060) return; + throw new Win32Exception(error); + } + try + { + var elapsed=Stopwatch.StartNew();var requested=false; + while(true) + { + if(!QueryServiceStatusEx(service,0,out var status,Marshal.SizeOf(),out _)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if(status.State==1) return; + if(elapsed.Elapsed>=TimeSpan.FromSeconds(30)) throw new IOException($"Global monitor did not stop (SCM state {status.State})."); + // Pending services cannot accept STOP. A successful request must + // also finish before deployment or service removal can continue. + if(!requested && status.State is 4 or 7) + { + var result=Sc("stop",ServiceName); + if(result==0) requested=true; + else if(result is not (1060 or 1062)) + { + if(result is not (1052 or 1061) || + !QueryServiceStatusEx(service,0,out status,Marshal.SizeOf(),out _) || status.State is not (1 or 2 or 3 or 5 or 6)) + throw new Win32Exception(result); + } + } + Thread.Sleep(100); + } + } + finally {CloseServiceHandle(service);} } - throw new IOException("Global monitor did not stop."); + finally {CloseServiceHandle(manager);} } public static string? GetUnavailableMessage() { @@ -164,9 +204,9 @@ public static void Stop() finally {CloseServiceHandle(manager);} } [StructLayout(LayoutKind.Sequential)] private struct ServiceStatus {public uint Type,State,Accepted,Exit,Specific,Checkpoint,Wait,ProcessId,Flags;} - [DllImport("advapi32.dll",CharSet=CharSet.Unicode)] private static extern IntPtr OpenSCManager(string? machine,string? database,uint access); - [DllImport("advapi32.dll",CharSet=CharSet.Unicode)] private static extern IntPtr OpenService(IntPtr manager,string name,uint access); - [DllImport("advapi32.dll")] private static extern bool QueryServiceStatusEx(IntPtr service,int level,out ServiceStatus status,int size,out int needed); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern IntPtr OpenSCManager(string? machine,string? database,uint access); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern IntPtr OpenService(IntPtr manager,string name,uint access); + [DllImport("advapi32.dll",SetLastError=true)] private static extern bool QueryServiceStatusEx(IntPtr service,int level,out ServiceStatus status,int size,out int needed); [DllImport("advapi32.dll")] private static extern bool CloseServiceHandle(IntPtr handle); private static void WriteSettings(GlobalMonitorSettings settings) { diff --git a/src/Pathman.Core/Localization/Strings.resx b/src/Pathman.Core/Localization/Strings.resx index 2e4ed01..b6b1478 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,32 @@ 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 + New category… + New path variable… + New category + A category with this name already exists. + New user path variable + New system path variable + The destination “{0}” is not in the Path variables category. Choose another destination. + The variable already exists. diff --git a/src/Pathman.Core/Localization/Strings.zh-CN.resx b/src/Pathman.Core/Localization/Strings.zh-CN.resx index 4e408ca..e3c011e 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,32 @@ 全局服务只能从管理员保护的本地安装目录部署,请重新安装到安全目录。 无法查询全局监控服务状态。 全局监控已启用,但服务未运行。请由管理员检查或重新启用全局设置。 + 请选择有效的路径变量,不能使用 PATH 或系统内置变量。 + 已获得修改来源识别授权 + 启用时需要管理员授权,仅用于识别修改来源 + 本次运行已获得系统变量写入授权 + 整理系统 PATH 前需要管理员授权 + 由全局服务自动整理 + 立即整理系统 PATH + 整理目标路径引用 + 整理目标 {0} 中已有路径,但 PATH 尚未引用它。 + 在相应作用域的 PATH 中添加 %{0}%。 + 将外部新环境变量归入 + 将外部添加的PATH项移至 + 用户变量归类目标 + 系统变量归类目标 + 用户PATH迁移目标 + 系统PATH迁移目标 + 全局系统PATH迁移目标 + 将外部新用户环境变量归入所选分类 + 将外部新系统环境变量归入所选分类 + 分类 + 新建分类… + 新建路径变量… + 新建分类 + 已存在同名分类。 + 新建用户路径变量 + 新建系统路径变量 + 整理目标“{0}”不在路径变量分类中,请重新选择。 + 变量已存在。 diff --git a/src/Pathman.Core/Localization/Text.cs b/src/Pathman.Core/Localization/Text.cs index 92ab8bb..a6f7ec2 100644 --- a/src/Pathman.Core/Localization/Text.cs +++ b/src/Pathman.Core/Localization/Text.cs @@ -26,6 +26,7 @@ public static bool SetLanguage(string language) return changed; } public static string Get(string key)=>resources.GetString(key,selectedCulture) ?? key; + internal static IEnumerable Translations(string key)=>new[]{key,Get(key),resources.GetString(key,CultureInfo.InvariantCulture)}.OfType().Distinct(StringComparer.Ordinal); internal static bool IsTranslation(string key,string? value)=>value is not null && (value==key || value==Get(key) || value==resources.GetString(key,CultureInfo.InvariantCulture)); public static string Format(string key,params object?[] arguments)=>string.Format(CultureInfo.CurrentCulture,Get(key),arguments); diff --git a/src/Pathman.Core/Versions/CommandLinePathSetting.cs b/src/Pathman.Core/Versions/CommandLinePathSetting.cs index daa2a6e..d977016 100644 --- a/src/Pathman.Core/Versions/CommandLinePathSetting.cs +++ b/src/Pathman.Core/Versions/CommandLinePathSetting.cs @@ -7,18 +7,7 @@ public sealed record CommandLinePathPlan(IReadOnlyList Muta public sealed class CommandLinePathSetting(PathmanLayout layout,VariableScope scope) { - 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 +19,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 && 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 +42,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 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 name=TargetVariable(state,scope); + var destination=variables.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals(name,StringComparison.OrdinalIgnoreCase)); + if(destination?.Entries.Any(Covers)!=true) Set(destination,name,Append(destination?.RawValue,layout.BinDirectory)); + 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/Dialogs/EditDialogs.cs b/src/Pathman.Gui/Dialogs/EditDialogs.cs index 9534894..9780825 100644 --- a/src/Pathman.Gui/Dialogs/EditDialogs.cs +++ b/src/Pathman.Gui/Dialogs/EditDialogs.cs @@ -41,7 +41,7 @@ public sealed class VariableDialog : Window public VariableScope Scope { get; } public string GroupId => ((VariableGroupDefinition)group.SelectedItem).Id; public string RawValue => lines.IsChecked == true ? string.Join(';', value.Text.Replace("\r\n", "\n").Replace('\r','\n').Split('\n')) : value.Text; - public VariableDialog(Window owner, VariableScope initialScope, IReadOnlyList groups, string initialName = "", string initialValue = "", bool list = false) + public VariableDialog(Window owner, VariableScope initialScope, IReadOnlyList groups, string initialName = "", string initialValue = "", bool list = false,string? fixedGroupId=null,Func? validateName=null) { Scope=initialScope; Owner = owner; Title = initialScope==VariableScope.User ? L.Get("新建用户环境变量") : L.Get("新建系统环境变量"); Width = 560; Height = 430; MinWidth = 460; MinHeight = 360; WindowStartupLocation = WindowStartupLocation.CenterOwner; @@ -52,14 +52,14 @@ public VariableDialog(Window owner, VariableScope initialScope, IReadOnlyListitem.Rule=="fallback") }; + group = new ComboBox { ItemsSource=groups, DisplayMemberPath=nameof(VariableGroupDefinition.DisplayName), SelectedItem=groups.FirstOrDefault(item=>item.Id==fixedGroupId) ?? groups.FirstOrDefault(item=>item.Rule=="fallback") ?? groups.First(),IsEnabled=fixedGroupId is null }; AutomationProperties.SetAutomationId(group,"VariableGroupInput"); AutomationProperties.SetName(group,L.Get("分组")); Grid.SetColumn(group,1); row.Children.Add(group); Grid.SetRow(row,1); grid.Children.Add(row); grid.Children.Add(new TextBlock { Text=L.Get("变量值") }); Grid.SetRow(grid.Children[^1],2); value = new Pathman.Gui.Controls.ReferenceTextBox { Text=list ? initialValue.Replace(";", Environment.NewLine) : initialValue, AcceptsReturn=true, TextWrapping=TextWrapping.Wrap, VerticalScrollBarVisibility=ScrollBarVisibility.Auto, Margin=new(0,6,0,0) }; AutomationProperties.SetAutomationId(value,"VariableValueInput"); Grid.SetRow(value,3); grid.Children.Add(value); lines = new CheckBox { Content=L.Get("每行一个条目(保存为分号分隔)"), IsChecked=list, Margin=new(0,10,0,0) }; Grid.SetRow(lines,4); grid.Children.Add(lines); var buttons = new StackPanel { Orientation=Orientation.Horizontal, HorizontalAlignment=HorizontalAlignment.Right, Margin=new(0,18,0,0) }; var ok = new Button { Content=L.Get("确定"), MinWidth=84, Margin=new(0,0,8,0) }; - ok.Click += (_,_) => { try { WindowsEnvironmentVariableEditor.ValidateName(name.Text); DialogResult=true; } catch (ArgumentException e) { MessageBox.Show(this,e.Message,L.Get("变量名无效")); } }; + ok.Click += (_,_) => { try { WindowsEnvironmentVariableEditor.ValidateName(name.Text);if(validateName?.Invoke(name.Text) is {} issue) throw new ArgumentException(issue);DialogResult=true; } catch (ArgumentException e) { MessageBox.Show(this,e.Message,L.Get("变量名无效")); } }; buttons.Children.Add(ok); buttons.Children.Add(new Button { Content=L.Get("取消"), MinWidth=84, IsCancel=true }); Grid.SetRow(buttons,5); grid.Children.Add(buttons); Content=grid; Loaded += (_,_) => name.Focus(); } 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/AliasesView.xaml.cs b/src/Pathman.Gui/Views/AliasesView.xaml.cs index ef6aac2..45dae8a 100644 --- a/src/Pathman.Gui/Views/AliasesView.xaml.cs +++ b/src/Pathman.Gui/Views/AliasesView.xaml.cs @@ -20,6 +20,7 @@ public partial class AliasesView : UserControl private readonly ObservableCollection rows=[]; private bool updating,busy,enabled,browsing; private int autoSaveEpoch; + private (EditableRow Editor,int Version,int Epoch)? outsideCommit; private EditableRow? activeEditor; private AliasRow? advanceToTarget; private Window? owner; @@ -32,12 +33,12 @@ public AliasesView(PathmanLayout layout,EnvironmentWorkspaceStore store) this.layout=layout;this.store=store;manager=new(layout,ensureUserPath:false);InitializeComponent();Aliases.ItemsSource=rows; PreviewKeyDown+=AliasKeyDown; Loaded+=(_,_)=> { - owner=Window.GetWindow(this);owner.PreviewMouseUp+=SaveAfterClick;owner.LostKeyboardFocus+=SaveAfterKeyboardFocus;owner.Deactivated+=SaveAfterDeactivation; + owner=Window.GetWindow(this);owner.AddHandler(Mouse.PreviewMouseUpEvent,new MouseButtonEventHandler(SaveAfterClick),true);owner.LostKeyboardFocus+=SaveAfterKeyboardFocus;owner.Deactivated+=SaveAfterDeactivation; statePoll.Start();Refresh(this,new()); }; Unloaded+=(_,_)=> { statePoll.Stop(); - if(owner is not null) {owner.PreviewMouseUp-=SaveAfterClick;owner.LostKeyboardFocus-=SaveAfterKeyboardFocus;owner.Deactivated-=SaveAfterDeactivation;owner=null;} + if(owner is not null) {owner.RemoveHandler(Mouse.PreviewMouseUpEvent,new MouseButtonEventHandler(SaveAfterClick));owner.LostKeyboardFocus-=SaveAfterKeyboardFocus;owner.Deactivated-=SaveAfterDeactivation;owner=null;} }; statePoll.Tick+=(_,_)=> { if(IsBusy) return; @@ -71,21 +72,27 @@ private async Task CommitActive() {return activeEditor?.IsEditing!=true || await activeEditor.CommitAsync();} private bool DefersAutoSave(DependencyObject? source)=>EditableRow.FindParent