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