Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions PathMan.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Pathman.Core.Tests/Pathman.Core.Tests.csproj" />
<Project Path="tests/Pathman.Gui.Tests/Pathman.Gui.Tests.csproj" />
</Folder>
</Solution>
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ PATH、PATHEXT 等分号列表支持多行与文本两种编辑模式。多行
变量管理页右上角的“清理”会打开双栏差异预览,点击“应用清理”后写入。

- 用户 PATH:展开引用后去重。
- 系统 PATH:去重后保留 Windows 系统目录与纯引用,将其余软件路径移入 `SOFTWARE_PATH` 并添加引用。名称已占用时使用 `SOFTWARE1_PATH`、`SOFTWARE2_PATH` 等。
- 系统 PATH:去重后保留 Windows 系统目录与纯引用,将其余软件路径移入所选整理目标并添加引用,默认使用 `EXTERNAL_PATH`。目标变量已有内容时追加路径。
- 直接条目与被引用变量中的路径重复时,优先保留引用,被引用变量的内容不变。

预览会提示搜索顺序是否改变,因为 PATH 顺序可能影响实际运行的命令。写入前备份原值和注册表类型;源值或引用发生变化后需重新预览。
Expand Down Expand Up @@ -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`。服务按用户令牌处理各自的用户变量与个人配置。关闭统一设置后,恢复符合个人后台运行和登录自启条件的后台程序。仅为自己安装时不显示全局设置。

Expand Down Expand Up @@ -128,7 +128,7 @@ PATH 不可作为版本目标。内置系统变量默认不可选,可在“设

## 版本管理 CLI

在版本管理页开启“启用命令行版本切换”后,打开新终端使用 `pathman`。该功能默认关闭,主窗口按安装范围将 `bin` 加入用户或系统 PATH;相应范围启用自动整理时,写入 `SOFTWARE_PATH` 或已有的 `SOFTWARE数字_PATH`。PATH 缺少该变量的纯引用时,健康检测会报告错误。
在版本管理页开启“启用命令行版本切换”后,打开新终端使用 `pathman`。该功能默认关闭,主窗口按安装范围将 `bin` 加入用户或系统 PATH;相应范围启用自动整理时,写入所选迁移目标,并在 PATH 中补上该变量的引用。

关闭开关会移除命令行目录并保留版本配置。未启用时直接运行版本命令会报错退出,`--help` 始终可用。

Expand Down
8 changes: 7 additions & 1 deletion scripts/test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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()}}
Expand Down
3 changes: 1 addition & 2 deletions src/Pathman.Core/EnvironmentVariables/Defaults/groups.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -61,7 +62,7 @@ public async Task ApplyAsync(IReadOnlyList<EnvironmentMutation> mutations,IReadO
{
await WriteAsync(pipe,new EnvironmentSessionRequest(mutations.ToArray(),preconditions.ToArray()),timeout.Token);
var result=await ReadAsync<EnvironmentSessionResult>(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);}
Expand All @@ -85,17 +86,19 @@ public static async Task<int> 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<EnvironmentSessionRequest>(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;
}
Expand Down
17 changes: 14 additions & 3 deletions src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public void SetEnabled(bool enabled,IReadOnlyList<EnvironmentVariableInfo>? capt
if (enabled) state.Snapshot = (captured ?? ReadCurrent()).Select(Snapshot).ToList();
});
public bool Poll(IEnvironmentChangeOrigins? origins=null,IReadOnlyList<EnvironmentVariableInfo>? captured=null)
{
if(!RefreshPlans(origins,captured)) return false;
return ApplyPlans(origins);
}
public bool RefreshPlans(IEnvironmentChangeOrigins? origins=null,IReadOnlyList<EnvironmentVariableInfo>? captured=null)
{
var paused=false;
store.Inspect(state=>
Expand All @@ -33,7 +38,10 @@ public bool Poll(IEnvironmentChangeOrigins? origins=null,IReadOnlyList<Environme
var changed=Compare(state,current,origins);
return PreparePlans(state,before,current,changed,runtime:runtime,origins:origins,managedScope:managedScope) || changed;
});
if(paused) return false;
return !paused;
}
private bool ApplyPlans(IEnvironmentChangeOrigins? origins)
{
var saved=store.Read();
if(!saved.MonitoringEnabled || !saved.AnyPathOrganizationEnabled) return false;
var applied=false;
Expand All @@ -50,11 +58,12 @@ public bool Poll(IEnvironmentChangeOrigins? origins=null,IReadOnlyList<Environme
WindowsEnvironmentVariableEditor.ApplyBatch(plan.Mutations,plan.Preconditions,store,state=>
{
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);}
}
Expand Down Expand Up @@ -85,7 +94,8 @@ internal static bool PreparePlans(EnvironmentWorkspace state,IReadOnlyList<Varia
}
if(pending is null && (!changed || old?.Value==path?.RawValue)) continue;
if(pending is not null && !changed && pending.Policy==ExternalPathOrganizer.Policy(state,scope) &&
pending.Mutations.All(mutation=>current.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);
Expand Down Expand Up @@ -137,6 +147,7 @@ internal static bool Compare(EnvironmentWorkspace state, IReadOnlyList<Environme
state.Snapshot = current.Select(Snapshot).ToList();
state.Changes.InsertRange(0, changes);
if (state.Changes.Count > 200) state.Changes.RemoveRange(200, state.Changes.Count - 200);
state.MaterializeExternalGroup();
EnvironmentMonitorNotices.Reconcile(state);
return true;
}
Expand Down
12 changes: 12 additions & 0 deletions src/Pathman.Core/EnvironmentVariables/EnvironmentMonitorNotices.cs
Original file line number Diff line number Diff line change
@@ -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)
{
Expand All @@ -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)
{
Expand Down
Loading