From 4cd007971e45d6879402454845160c4c32478a16 Mon Sep 17 00:00:00 2001 From: Napreth Date: Fri, 30 Oct 2026 15:32:48 +0800 Subject: [PATCH 1/6] Avoid login UAC prompts with a scheduled origin collector --- .github/workflows/build.yml | 3 + README.md | 2 +- packaging/PathMan.nsi | 12 ++ packaging/native/actions.cpp | 34 +++++ src/Pathman.Core/Localization/Strings.resx | 22 ++- .../Localization/Strings.zh-CN.resx | 22 ++- src/Pathman.Core/OriginTrackingTask.cs | 132 ++++++++++++++++++ src/Pathman.Gui/MainWindow.xaml.cs | 2 +- src/Pathman.Gui/Views/MonitorView.xaml | 30 +++- src/Pathman.Gui/Views/MonitorView.xaml.cs | 14 +- src/Pathman.Service/InstallerOriginBroker.cs | 4 +- src/Pathman.Service/Program.cs | 23 +++ src/Pathman.Service/RegistryOriginTracker.cs | 45 ++++-- tests/gui-settings.ps1 | 4 +- tests/limited-process.cs | 76 ++++++++++ tests/origin-task.ps1 | 108 ++++++++++++++ 16 files changed, 503 insertions(+), 30 deletions(-) create mode 100644 src/Pathman.Core/OriginTrackingTask.cs create mode 100644 tests/limited-process.cs create mode 100644 tests/origin-task.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b9b5a1..48df8cb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,6 +100,9 @@ jobs: run: ./build.ps1 -Arch $env:PATHMAN_ARCH -VersionName '0.0.0' -VersionNumber '0.0.0' -ReleaseType official - name: Build, run and package run: ./build.ps1 -Arch $env:PATHMAN_ARCH -VersionName $env:PATHMAN_VERSION -VersionNumber $env:PATHMAN_VERSION_NUMBER -ReleaseType $env:PATHMAN_RELEASE_TYPE -Test + - name: Test origin tracking authorization + if: matrix.arch == 'x64' && !env.ACT + run: ./tests/origin-task.ps1 - name: Accept final installers if: ${{ !env.ACT }} run: ./tests/installer-acceptance.ps1 -Architecture $env:PATHMAN_ARCH -Version $env:PATHMAN_VERSION diff --git a/README.md b/README.md index bc35721..b68159b 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ PATH 不可作为版本目标。内置系统变量默认不可选,可在“设 用户和系统范围分别提供外部变量归类、PATH 去重与迁移选项,也可排除系统环境编辑器或 PowerToys 的修改。用户外部路径迁入 `EXTERNAL_USER_PATH`,系统外部路径迁入 `EXTERNAL_PATH`。 -“精确识别修改来源”需要管理员权限,用于记录写入进程并应用排除规则;来源无法确认时保留原值。系统 PATH 自动整理也需要管理员授权。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。 +“精确识别修改来源”首次启用时需要管理员授权,之后由计划任务启动只读的来源采集程序,登录时不会重复弹出 UAC。监控主进程仍以当前用户权限运行。来源无法确认时保留原值,系统 PATH 自动整理仍需单独授权。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。 ### 设置与关于 diff --git a/packaging/PathMan.nsi b/packaging/PathMan.nsi index e56af42..12d36ff 100644 --- a/packaging/PathMan.nsi +++ b/packaging/PathMan.nsi @@ -670,6 +670,12 @@ Section "PathMan" Main SetErrorLevel 1603 Abort ${EndIf} + System::Call '$Helper::MaintainOriginTask(w "$INSTDIR", w "$UserLocalData", w "$OriginalUserSid") i.r0' + ${If} $0 != 0 + MessageBox MB_OK|MB_ICONSTOP "$(MaintenanceFailed)" /SD IDOK + SetErrorLevel 1603 + Abort + ${EndIf} InstallUserFinalization: ${If} $Worker == 1 Call RestoreBackground @@ -752,6 +758,12 @@ Section "Uninstall" SetErrorLevel 0 Return ${EndIf} + System::Call '$Helper::RemoveOriginTasks(w "$INSTDIR") i.r0' + ${If} $0 != 0 + MessageBox MB_OK|MB_ICONSTOP "$(MaintenanceFailed)" /SD IDOK + SetErrorLevel 1603 + Abort + ${EndIf} System::Call '$Helper::CleanupInstall(w "$INSTDIR", i $Machine, w "$DESKTOP") i.r0' ${If} $0 != 0 MessageBox MB_OK|MB_ICONSTOP "$(MaintenanceFailed)" /SD IDOK diff --git a/packaging/native/actions.cpp b/packaging/native/actions.cpp index 9abc408..05ffa7d 100644 --- a/packaging/native/actions.cpp +++ b/packaging/native/actions.cpp @@ -31,6 +31,8 @@ #pragma comment(linker, "/EXPORT:BeginSetup=_BeginSetup@0") #pragma comment(linker, "/EXPORT:CleanupUserInstall=_CleanupUserInstall@8") #pragma comment(linker, "/EXPORT:PrepareOriginBroker=_PrepareOriginBroker@12") +#pragma comment(linker, "/EXPORT:RemoveOriginTasks=_RemoveOriginTasks@4") +#pragma comment(linker, "/EXPORT:MaintainOriginTask=_MaintainOriginTask@12") #endif extern "C" __declspec(dllexport) DWORD __stdcall CompareVersions(const wchar_t* left,const wchar_t* right) { @@ -470,6 +472,38 @@ DWORD run_verified_elevated(HWND owner, const std::filesystem::path& worker, con return result; } } +namespace { +DWORD origin_maintenance(const wchar_t* directory, const std::wstring& parameters) { + try { + const auto executable = std::filesystem::path(directory) / L"internal" / L"PathMan.Service.exe"; + if (!std::filesystem::exists(executable)) return ERROR_SUCCESS; + auto command = L"\"" + executable.wstring() + L"\" " + parameters; + STARTUPINFOW start{sizeof(start)}; PROCESS_INFORMATION process{}; + if (!CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, directory, &start, &process)) return GetLastError(); + wait_with_messages(process.hProcess); + DWORD result = ERROR_INSTALL_FAILURE; + GetExitCodeProcess(process.hProcess, &result); + CloseHandle(process.hThread); CloseHandle(process.hProcess); + if (result != ERROR_ELEVATION_REQUIRED) return result; + SHELLEXECUTEINFOW execute{sizeof(execute)}; + execute.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI; + execute.lpVerb = L"runas"; execute.lpFile = executable.c_str(); execute.lpParameters = parameters.c_str(); execute.nShow = SW_HIDE; + if (!ShellExecuteExW(&execute)) return GetLastError(); + if (!execute.hProcess) return ERROR_INSTALL_FAILURE; + wait_with_messages(execute.hProcess); + GetExitCodeProcess(execute.hProcess, &result); CloseHandle(execute.hProcess); + return result; + } catch (...) { return ERROR_INSTALL_FAILURE; } +} +} +extern "C" __declspec(dllexport) DWORD __stdcall RemoveOriginTasks(const wchar_t* directory) { + return origin_maintenance(directory, L"--remove-origin-tasks"); +} +extern "C" __declspec(dllexport) DWORD __stdcall MaintainOriginTask(const wchar_t* directory, const wchar_t* localData, const wchar_t* originalSid) { + const auto sid = originalSid && *originalSid ? std::wstring(originalSid) : current_sid(); + const auto data = std::filesystem::path(localData) / L"PathMan"; + return origin_maintenance(directory, L"--maintain-origin-task \"" + sid + L"\" \"" + data.wstring() + L"\""); +} extern "C" __declspec(dllexport) DWORD __stdcall PrepareOriginBroker(const wchar_t* directory, const wchar_t* localData, const wchar_t* originalSid) { try { const auto sid = originalSid && *originalSid ? std::wstring(originalSid) : current_sid(); diff --git a/src/Pathman.Core/Localization/Strings.resx b/src/Pathman.Core/Localization/Strings.resx index 26e93d5..7881001 100644 --- a/src/Pathman.Core/Localization/Strings.resx +++ b/src/Pathman.Core/Localization/Strings.resx @@ -982,8 +982,26 @@ The administrator connection timed out. Reload to confirm the result before retrying. - - Identify the writing process precisely (requires administrator privileges) + + Identify the writing process precisely + + + Requires administrator privileges + + + Requires precise identification of the writing process + + + Cannot access the origin tracking scheduled task. + + + Cannot register the origin tracking scheduled task. + + + Cannot start the origin tracking scheduled task. + + + Turn precise origin tracking off and on to approve its one-time administrator setup. System diff --git a/src/Pathman.Core/Localization/Strings.zh-CN.resx b/src/Pathman.Core/Localization/Strings.zh-CN.resx index 4197cf4..5b6d6ab 100644 --- a/src/Pathman.Core/Localization/Strings.zh-CN.resx +++ b/src/Pathman.Core/Localization/Strings.zh-CN.resx @@ -982,8 +982,26 @@ 管理员连接超时,请刷新确认写入结果后重试。 - - 精确识别修改来源(需要管理员权限) + + 精确识别修改来源 + + + 需要管理员权限 + + + 此功能需要开启精确识别修改来源 + + + 无法访问来源识别计划任务。 + + + 无法注册来源识别计划任务。 + + + 无法启动来源识别计划任务。 + + + 请重新开启“精确识别修改来源”,完成一次管理员授权。 系统 diff --git a/src/Pathman.Core/OriginTrackingTask.cs b/src/Pathman.Core/OriginTrackingTask.cs new file mode 100644 index 0000000..cf37b81 --- /dev/null +++ b/src/Pathman.Core/OriginTrackingTask.cs @@ -0,0 +1,132 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using System.Xml.Linq; + +namespace Pathman.Core; + +// Only the read-only collector runs as SYSTEM. The monitor retains the desktop +// user's token and HKCU. No task parameters select executable paths or commands. +public sealed class OriginTrackingTask(PathmanLayout layout) +{ + private string InstallationId => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(layout.BackgroundExecutable.ToUpperInvariant())))[..24]; + private string Prefix => "PathMan.Origins." + InstallationId + "."; + private static string CurrentSid {get {using var user=WindowsIdentity.GetCurrent();return user.User!.Value;}} + private string DirectoryFor(string sid) => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),"PathMan Origin Collectors",InstallationId,sid); + public string Executable => Path.Combine(DirectoryFor(CurrentSid),"PathMan.Service.exe"); + private string ArgumentsFor(string sid) => $"--scheduled-origin-worker \"{layout.BackgroundExecutable}\" {sid} \"$(Arg0)\" \"$(Arg1)\" \"$(Arg2)\" \"$(Arg3)\""; + private static dynamic Connect() + { + dynamic scheduler=Activator.CreateInstance(Type.GetTypeFromProgID("Schedule.Service",throwOnError:true)!)!; + scheduler.Connect();return scheduler.GetFolder(@"\"); + } + private dynamic? Find(dynamic folder,string sid) + { + try {return folder.GetTask(Prefix+sid);} + catch(FileNotFoundException) {return null;} + catch(DirectoryNotFoundException) {return null;} + catch(COMException error) when(error.HResult==unchecked((int)0x80070002)) {return null;} + } + public bool IsRegistered + { + get + { + try + { + dynamic? task=Find(Connect(),CurrentSid); + if(task is null || !task.Enabled) return false; + dynamic definition=task!.Definition; + return ((string)definition.Principal.UserId is "SYSTEM" or "S-1-5-18") && definition.Actions.Count==1 && + string.Equals((string)definition.Actions.Item(1).Path,Executable,StringComparison.OrdinalIgnoreCase) && + definition.Actions.Item(1).Arguments==ArgumentsFor(CurrentSid) && File.Exists(Executable); + } + catch(COMException error) {throw new IOException(L.Get("无法访问来源识别计划任务。"),error);} + } + } + public void EnsureRegistered() + { + if(IsRegistered && FileHash(Executable).SequenceEqual(FileHash(layout.BackgroundExecutable))) return; + using var user=WindowsIdentity.GetCurrent(); + var start=new ProcessStartInfo(layout.BackgroundExecutable) {UseShellExecute=true,Verb="runas",WindowStyle=ProcessWindowStyle.Hidden}; + start.ArgumentList.Add("--register-origin-task");start.ArgumentList.Add(user.User!.Value); + using var process=Process.Start(start) ?? throw new IOException(L.Get("无法注册来源识别计划任务。")); + if(!process.WaitForExit(60000) || process.ExitCode!=0 || !IsRegistered) throw new IOException(L.Get("无法注册来源识别计划任务。")); + } + private static byte[] FileHash(string path) {using var input=File.OpenRead(path);return SHA256.HashData(input);} + public void Start(string[] arguments) + { + if(arguments.Length!=4 || !IsRegistered) throw new IOException(L.Get("请重新开启“精确识别修改来源”,完成一次管理员授权。")); + try {dynamic task=Find(Connect(),CurrentSid)!;task.Run(arguments);} + catch(COMException error) {throw new IOException(L.Get("无法启动来源识别计划任务。"),error);} + } + public void Register(string sid) + { + sid=new SecurityIdentifier(sid).Value; + DemandAdministrator(); + dynamic folder=Connect();dynamic? old=Find(folder,sid);old?.Stop(0); + var target=DirectoryFor(sid); + // Program Files is a trusted anchor even for a per-user installation. + // Do not follow junctions or reuse a directory owned by an ordinary user. + var root=Path.GetDirectoryName(Path.GetDirectoryName(target)!)!; + SecureDirectory(root);SecureDirectory(Path.GetDirectoryName(target)!);SecureDirectory(target); + var source=Path.GetDirectoryName(layout.BackgroundExecutable)!; + foreach(var file in Directory.EnumerateFiles(source)) + { + var name=Path.GetFileName(file);var extension=Path.GetExtension(file); + if(!name.Equals("PathMan.Service.exe",StringComparison.OrdinalIgnoreCase) && extension is not (".dll" or ".json")) continue; + if((File.GetAttributes(file)&FileAttributes.ReparsePoint)!=0) throw new IOException("Collector files cannot be reparse points."); + File.Copy(file,Path.Combine(target,name),overwrite:true); + } + XNamespace ns="http://schemas.microsoft.com/windows/2004/02/mit/task"; + XElement Element(string name,object value)=>new(ns+name,value); + var xml=new XDocument(new XElement(ns+"Task",new XAttribute("version","1.2"), + Element("Principals",new XElement(ns+"Principal",new XAttribute("id","Collector"),Element("UserId","S-1-5-18"),Element("RunLevel","HighestAvailable"))), + new XElement(ns+"Settings",Element("MultipleInstancesPolicy","Parallel"),Element("DisallowStartIfOnBatteries","false"),Element("StopIfGoingOnBatteries","false"), + Element("AllowStartOnDemand","true"),Element("Enabled","true"),Element("ExecutionTimeLimit","PT0S")), + new XElement(ns+"Actions",new XAttribute("Context","Collector"),new XElement(ns+"Exec",Element("Command",Path.Combine(target,"PathMan.Service.exe")),Element("Arguments",ArgumentsFor(sid)),Element("WorkingDirectory",target))))); + // The desktop user may read/run the fixed task, but cannot edit it. + folder.RegisterTask(Prefix+sid,xml.ToString(),6,"SYSTEM",null,5,$"D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGX;;;{sid})"); + } + private static void SecureDirectory(string path) + { + var directory=Directory.CreateDirectory(path); + if((directory.Attributes&FileAttributes.ReparsePoint)!=0) throw new IOException("Collector directories cannot be reparse points."); + var security=directory.GetAccessControl();var owner=security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + var system=new SecurityIdentifier(WellKnownSidType.LocalSystemSid,null); + var admins=new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid,null); + using var identity=WindowsIdentity.GetCurrent(); + if(owner!=system && owner!=admins && owner!=identity.User) throw new UnauthorizedAccessException("Untrusted collector directory owner."); + security=new DirectorySecurity();security.SetOwner(admins);security.SetAccessRuleProtection(true,false); + foreach(var principal in new[] {system,admins}) security.AddAccessRule(new(principal,FileSystemRights.FullControl,InheritanceFlags.ContainerInherit|InheritanceFlags.ObjectInherit,PropagationFlags.None,AccessControlType.Allow)); + security.AddAccessRule(new(new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid,null),FileSystemRights.ReadAndExecute,InheritanceFlags.ContainerInherit|InheritanceFlags.ObjectInherit,PropagationFlags.None,AccessControlType.Allow)); + directory.SetAccessControl(security); + } + public bool HasInstallationTasks() + { + foreach(dynamic task in Connect().GetTasks(1)) if(((string)task.Name).StartsWith(Prefix,StringComparison.Ordinal)) return true; + return false; + } + public bool HasUserTask(string sid) => Find(Connect(),new SecurityIdentifier(sid).Value) is not null; + public void RemoveInstallation() + { + var directory=Path.GetDirectoryName(DirectoryFor(CurrentSid))!; + if(!HasInstallationTasks() && !Directory.Exists(directory)) return; + DemandAdministrator();dynamic folder=Connect(); + foreach(dynamic task in folder.GetTasks(1)) + if(((string)task.Name).StartsWith(Prefix,StringComparison.Ordinal)) {task.Stop(0);folder.DeleteTask(task.Name,0);} + if(Directory.Exists(directory)) + { + if((File.GetAttributes(directory)&FileAttributes.ReparsePoint)!=0) throw new IOException("Invalid collector cleanup directory."); + Directory.Delete(directory,true); + } + } + private static void DemandAdministrator() + { + using var identity=WindowsIdentity.GetCurrent(); + if(!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) throw new Win32Exception(740); + } +} diff --git a/src/Pathman.Gui/MainWindow.xaml.cs b/src/Pathman.Gui/MainWindow.xaml.cs index 33c7ea4..6f9e484 100644 --- a/src/Pathman.Gui/MainWindow.xaml.cs +++ b/src/Pathman.Gui/MainWindow.xaml.cs @@ -39,7 +39,7 @@ public MainWindow() InitializeComponent();Language=System.Windows.Markup.XmlLanguage.GetLanguage(L.Current.CultureName);RefreshWindowTitle(null,EventArgs.Empty);store=new(layout.DataDirectory); updates=new(layout.DataDirectory,release=>UpdateToast.Show(release,layout),store.Read().Preferences.ReceivePrereleaseUpdates); variables=new(layout,store); aliases=new(layout,store); monitor=new(layout,store); about=new(updates,InstallUpdateAsync);settings=new(store); - versions=new(layout,store,InstalledScope()=="/AllUsers" ? Pathman.Core.Models.VariableScope.Machine : Pathman.Core.Models.VariableScope.User);AppVersion.Text=AppInfo.DisplayVersion; + versions=new(layout,store,InstalledScope()=="/AllUsers" ? Pathman.Core.Models.VariableScope.Machine : Pathman.Core.Models.VariableScope.User);AppVersion.Text=AppInfo.Version; settings.PreferencesChanged+=async(_,_)=>await ApplyPreferencesAsync(); PageHost.Content=variables; previousNavigation=Navigation.SelectedItem as ListBoxItem; NotificationsList.ItemsSource=notifications.Items; diff --git a/src/Pathman.Gui/Views/MonitorView.xaml b/src/Pathman.Gui/Views/MonitorView.xaml index ddb2de0..f8b0678 100644 --- a/src/Pathman.Gui/Views/MonitorView.xaml +++ b/src/Pathman.Gui/Views/MonitorView.xaml @@ -9,7 +9,10 @@ - + + + + @@ -24,8 +27,14 @@ - - + + + + + + + + @@ -36,13 +45,22 @@ - + + + + - - + + + + + + + + diff --git a/src/Pathman.Gui/Views/MonitorView.xaml.cs b/src/Pathman.Gui/Views/MonitorView.xaml.cs index c8615b3..5a90ca5 100644 --- a/src/Pathman.Gui/Views/MonitorView.xaml.cs +++ b/src/Pathman.Gui/Views/MonitorView.xaml.cs @@ -69,7 +69,7 @@ private async void ChangeStartup(object sender,RoutedEventArgs e) var enabled=StartupEnabled.IsChecked==true;busy=true;SettingsPanel.IsEnabled=false; try { - await Task.Run(()=> {new StartupManager(layout).SetEnabled(enabled);var process=new BackgroundProcess(layout);if(enabled) process.Start();else process.Stop();}); + await Task.Run(()=> {if(enabled && store.Read().TrackChangeOrigins) new OriginTrackingTask(layout).EnsureRegistered();new StartupManager(layout).SetEnabled(enabled);var process=new BackgroundProcess(layout);if(enabled) process.Start();else process.Stop();}); busy=false;Update(store.Read()); } catch(Exception error) when(Expected(error)) {RefreshProcessState();ShowError(error);} @@ -85,7 +85,7 @@ await Task.Run(()=> { new EnvironmentMonitor(store).SetEnabled(enabled); var process=new BackgroundProcess(layout);var startup=new StartupManager(layout); - if(enabled) {if(startup.IsEnabled) process.Start();} + if(enabled) {if(startup.IsEnabled) {if(store.Read().TrackChangeOrigins) new OriginTrackingTask(layout).EnsureRegistered();process.Start();}} else {startup.SetEnabled(false);process.Stop();} }); busy=false;Update(store.Read());OptionsChanged?.Invoke(this,EventArgs.Empty); @@ -93,11 +93,14 @@ await Task.Run(()=> catch(Exception error) when(Expected(error)) {RefreshProcessState();ShowError(error);} finally {busy=false;SettingsPanel.IsEnabled=true;} } - private void ChangeOptions(object sender,RoutedEventArgs e) + private async void ChangeOptions(object sender,RoutedEventArgs e) { if(updating || busy) return; + busy=true;SettingsPanel.IsEnabled=false; try { + if(ReferenceEquals(sender,TrackOrigins) && TrackOrigins.IsChecked==true) + await Task.Run(()=>new OriginTrackingTask(layout).EnsureRegistered()); store.Update(state=> { state.GroupExternalUserVariables=GroupExternalUser.IsChecked==true;state.GroupExternalMachineVariables=GroupExternalMachine.IsChecked==true; @@ -110,9 +113,10 @@ private void ChangeOptions(object sender,RoutedEventArgs e) state.IgnorePowerToysUserChanges=IgnorePowerToysUser.IsChecked==true;state.IgnorePowerToysMachineChanges=IgnorePowerToysMachine.IsChecked==true; state.PendingPathOrganizations.RemoveAll(plan=>!state.IsPathOrganizationEnabled(plan.Scope)); }); - Update(store.Read());OptionsChanged?.Invoke(this,EventArgs.Empty); + busy=false;Update(store.Read());OptionsChanged?.Invoke(this,EventArgs.Empty); } - catch(Exception error) when(Expected(error)) {Update(store.Read());ShowError(error);} + catch(Exception error) when(Expected(error)) {busy=false;Update(store.Read());ShowError(error);} + finally {busy=false;SettingsPanel.IsEnabled=true;} } private void ApplyPendingChanges(object sender,RoutedEventArgs e)=>PendingApplyRequested?.Invoke(this,EventArgs.Empty); public void SetApplying(bool value) {busy=value;SettingsPanel.IsEnabled=!value;} diff --git a/src/Pathman.Service/InstallerOriginBroker.cs b/src/Pathman.Service/InstallerOriginBroker.cs index 2e8c7a2..c3fd225 100644 --- a/src/Pathman.Service/InstallerOriginBroker.cs +++ b/src/Pathman.Service/InstallerOriginBroker.cs @@ -8,6 +8,7 @@ using System.Text; using System.Text.Json; using Microsoft.Win32.SafeHandles; +using Pathman.Core; using Pathman.Core.EnvironmentVariables; namespace Pathman.Service; @@ -54,6 +55,7 @@ public static int Run(string[] args) if(!File.Exists(config)) return 0; var settings=JsonSerializer.Deserialize(File.ReadAllText(config),EnvironmentWorkspaceStore.JsonOptions); if(settings is not {MonitoringEnabled:true,TrackChangeOrigins:true}) return 0; + var task=new OriginTrackingTask(PathmanLayout.Discover());if(!task.HasUserTask(sid.Value)) task.Register(sid.Value); var security=new PipeSecurity();security.SetAccessRuleProtection(true,false); security.AddAccessRule(new(sid,PipeAccessRights.ReadWrite|PipeAccessRights.Synchronize,AccessControlType.Allow)); security.AddAccessRule(new(identity.User!,PipeAccessRights.FullControl,AccessControlType.Allow)); @@ -87,7 +89,7 @@ public static int Run(string[] args) output.WriteLine((collector?.Id ?? 0).ToString(CultureInfo.InvariantCulture)); return collector is null ? 1 : 0; } - catch(Exception error) when(error is IOException or UnauthorizedAccessException or OperationCanceledException or ArgumentException or InvalidOperationException or JsonException or System.ComponentModel.Win32Exception or FormatException) {return 1;} + catch(Exception error) when(error is IOException or UnauthorizedAccessException or OperationCanceledException or ArgumentException or InvalidOperationException or JsonException or System.ComponentModel.Win32Exception or FormatException or System.Runtime.InteropServices.COMException) {return 1;} } [DllImport("kernel32.dll",SetLastError=true)] private static extern bool GetNamedPipeClientProcessId(SafePipeHandle pipe,out uint id); [DllImport("kernel32.dll",SetLastError=true)] private static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe,out uint id); diff --git a/src/Pathman.Service/Program.cs b/src/Pathman.Service/Program.cs index 51a5f7b..69cc736 100644 --- a/src/Pathman.Service/Program.cs +++ b/src/Pathman.Service/Program.cs @@ -5,6 +5,29 @@ // A normal user-session background process, with no SCM registration or elevation. if(args.FirstOrDefault()=="--registry-origin-worker") return RegistryOriginTracker.RunWorker(args); +if(args.FirstOrDefault()=="--scheduled-origin-worker") return RegistryOriginTracker.RunScheduledWorker(args); +if(args is ["--register-origin-task",var sid]) +{ + try {new OriginTrackingTask(PathmanLayout.Discover()).Register(sid);return 0;} + catch(Exception error) when(error is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or System.Runtime.InteropServices.COMException or ArgumentException) {Console.Error.WriteLine(error);return error is System.ComponentModel.Win32Exception windows ? windows.NativeErrorCode : 1;} +} +if(args is ["--remove-origin-tasks"]) +{ + try {new OriginTrackingTask(PathmanLayout.Discover()).RemoveInstallation();return 0;} + catch(Exception error) when(error is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or System.Runtime.InteropServices.COMException) {return error is System.ComponentModel.Win32Exception windows ? windows.NativeErrorCode : 1;} +} +if(args is ["--maintain-origin-task",var originalSid,var originalData]) +{ + try + { + var config=Path.Combine(Path.GetFullPath(originalData),"environment.json"); + var task=new OriginTrackingTask(PathmanLayout.Discover()); + var enabled=File.Exists(config) && System.Text.Json.JsonSerializer.Deserialize(File.ReadAllText(config),EnvironmentWorkspaceStore.JsonOptions)?.TrackChangeOrigins==true; + if(enabled || task.HasUserTask(originalSid)) task.Register(originalSid); + return 0; + } + catch(Exception error) when(error is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception or System.Runtime.InteropServices.COMException or System.Text.Json.JsonException or ArgumentException) {Console.Error.WriteLine(error);return error is System.ComponentModel.Win32Exception windows ? windows.NativeErrorCode : 1;} +} if(args.FirstOrDefault()=="--installer-origin-broker") return InstallerOriginBroker.Run(args); // Installer maintenance is independent of the user's background monitoring switches. if(args is ["--installer-refresh-shims",var dataDirectory]) diff --git a/src/Pathman.Service/RegistryOriginTracker.cs b/src/Pathman.Service/RegistryOriginTracker.cs index eefcd6c..214b290 100644 --- a/src/Pathman.Service/RegistryOriginTracker.cs +++ b/src/Pathman.Service/RegistryOriginTracker.cs @@ -15,6 +15,7 @@ using Microsoft.Diagnostics.Tracing.Session; using Microsoft.Win32; using Microsoft.Win32.SafeHandles; +using Pathman.Core; using Pathman.Core.EnvironmentVariables; using Pathman.Core.Models; @@ -27,14 +28,14 @@ internal sealed record OriginMessage(string Kind,string? Message=null,Environmen internal sealed class RegistryOriginTracker : IEnvironmentChangeOrigins,IDisposable { private readonly NamedPipeServerStream pipe; - private readonly Process worker; + private readonly Process? worker; private readonly ConcurrentQueue captured=new(); private readonly CancellationTokenSource stop=new(); private readonly Task reader; private volatile bool ready; - public bool IsReady=>ready && !worker.HasExited; + public bool IsReady=>ready && (worker is null || !worker.HasExited); public string? Error {get;private set;} - private RegistryOriginTracker(NamedPipeServerStream pipe,Process worker) + private RegistryOriginTracker(NamedPipeServerStream pipe,Process? worker) { this.pipe=pipe;this.worker=worker; reader=Task.Run(async()=> @@ -70,10 +71,12 @@ public static RegistryOriginTracker Start() using var parent=Process.GetCurrentProcess(); var arguments=new[] {"--registry-origin-worker",parent.Id.ToString(CultureInfo.InvariantCulture),parent.StartTime.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture),name,secret,identity.User!.Value}; child=!IsAdministrator() ? InstallerOriginBroker.TryStart(arguments) : null; - if(child is null) + var scheduled=child is null && !IsAdministrator(); + var task=new OriginTrackingTask(PathmanLayout.Discover()); + if(scheduled) task.Start(arguments[1..5]); + else if(child is null) { - var start=new ProcessStartInfo(Environment.ProcessPath!) {UseShellExecute=!IsAdministrator(),WindowStyle=ProcessWindowStyle.Hidden}; - if(start.UseShellExecute) start.Verb="runas"; + var start=new ProcessStartInfo(Environment.ProcessPath!) {UseShellExecute=false,CreateNoWindow=true}; foreach(var value in arguments) start.ArgumentList.Add(value); child=Process.Start(start) ?? throw new IOException(L.Get("无法启动修改来源跟踪。")); } @@ -81,7 +84,18 @@ public static RegistryOriginTracker Start() while(true) { pipe.WaitForConnectionAsync(timeout.Token).GetAwaiter().GetResult(); - if(GetNamedPipeClientProcessId(pipe.SafePipeHandle,out var id) && id==child.Id) break; + if(GetNamedPipeClientProcessId(pipe.SafePipeHandle,out var id)) + { + if(child is not null && id==child.Id) break; + if(scheduled) + { + string? sid=null;pipe.RunAsClient(()=> {using var user=WindowsIdentity.GetCurrent(true);sid=user?.User?.Value;}); + // A limited caller cannot query a SYSTEM process image. + // The protected task fixes the image; authenticate its + // SYSTEM pipe token and the per-launch secret instead. + if(sid=="S-1-5-18") break; + } + } pipe.Disconnect(); } // Read one byte at a time for the handshake so buffering cannot steal @@ -106,16 +120,26 @@ private sealed class BatchOrigins(bool ready,IReadOnlyListready; public IReadOnlyList Take(VariableScope scope,string name)=>values.Where(item=>item.Scope==scope && item.Name.Equals(name,StringComparison.OrdinalIgnoreCase)).ToArray(); } - public void Dispose() {stop.Cancel();pipe.Dispose();worker.Dispose();_ = reader.ContinueWith(_=>stop.Dispose(),TaskScheduler.Default);} + public void Dispose() {stop.Cancel();pipe.Dispose();worker?.Dispose();_ = reader.ContinueWith(_=>stop.Dispose(),TaskScheduler.Default);} - public static int RunWorker(string[] args) + public static int RunScheduledWorker(string[] args) + { + if(args is not ["--scheduled-origin-worker",var source,var sid,var pid,var ticks,var name,var secret]) return 2; + return RunWorker(["--registry-origin-worker",pid,ticks,name,secret,sid],source); + } + public static int RunWorker(string[] args,string? expectedParent=null) { try { if(args.Length!=6 || !int.TryParse(args[1],out var parentId) || !long.TryParse(args[2],out var ticks) || !args[3].StartsWith("PathMan.RegistryOrigins.",StringComparison.Ordinal) || args[3].Length>100 || args[4].Length!=44 || !IsAdministrator()) return 2; using var parent=Process.GetProcessById(parentId); - if(parent.StartTime.ToUniversalTime().Ticks!=ticks || !Image(parent).Equals(Environment.ProcessPath,StringComparison.OrdinalIgnoreCase)) return 2; + if(parent.StartTime.ToUniversalTime().Ticks!=ticks || !Image(parent).Equals(expectedParent ?? Environment.ProcessPath,StringComparison.OrdinalIgnoreCase)) return 2; var sid=new SecurityIdentifier(args[5]).Value; + using(var handle=OpenProcess(0x1000,false,parent.Id)) + { + if(!OpenProcessToken(handle,8,out var token)) return 2; + using(token) using(var user=new WindowsIdentity(token.DangerousGetHandle())) if(user.User?.Value!=sid) return 2; + } using var pipe=new NamedPipeClientStream(".",args[3],PipeDirection.InOut,PipeOptions.Asynchronous,TokenImpersonationLevel.Identification); pipe.Connect(30000); if(!GetNamedPipeServerProcessId(pipe.SafePipeHandle,out var server) || server!=parentId) return 2; @@ -209,6 +233,7 @@ internal static string Image(Process process) } [DllImport("kernel32.dll",SetLastError=true)] private static extern bool GetNamedPipeClientProcessId(SafePipeHandle pipe,out uint id); [DllImport("kernel32.dll",SetLastError=true)] private static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe,out uint id); + [DllImport("advapi32.dll",SetLastError=true)] private static extern bool OpenProcessToken(SafeProcessHandle process,uint access,out SafeAccessTokenHandle token); [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern bool QueryFullProcessImageName(SafeProcessHandle process,int flags,StringBuilder path,ref int size); [DllImport("kernel32.dll",SetLastError=true)] private static extern SafeProcessHandle OpenProcess(int access,[MarshalAs(UnmanagedType.Bool)]bool inherit,int processId); } diff --git a/tests/gui-settings.ps1 b/tests/gui-settings.ps1 index 4e73a2b..0c4bd69 100644 --- a/tests/gui-settings.ps1 +++ b/tests/gui-settings.ps1 @@ -34,7 +34,7 @@ try{ StartApp $info=[Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $AppDirectory 'PathMan.exe')) $expected=$info.ProductVersion.Split('+')[0]+' ('+([version]$info.FileVersion).ToString(3)+')' - Assert ((Id 'AppVersion').Current.Name -eq $expected) 'Sidebar version is read from assembly metadata' + Assert ((Id 'AppVersion').Current.Name -eq $info.ProductVersion.Split('+')[0]) 'Sidebar shows the product version without a build number' $nav=(Id 'Navigation').FindAll([Windows.Automation.TreeScope]::Children,[Windows.Automation.Condition]::TrueCondition) Assert ($nav.Count -eq 7 -and $nav[$nav.Count-2].Current.Name -eq '设置' -and $nav[$nav.Count-1].Current.Name -eq '关于') 'Settings is immediately above About' Go '设置';WaitFor {$null -ne (Id 'LanguageChoice')} 'Settings page missing' @@ -58,7 +58,7 @@ try{ Click (Id 'AdvancedSettingsLink');WaitFor {$null -ne (Id 'AllowBuiltInVersionTargets')} 'Advanced page missing' Assert ($null -ne (Find $window 'Allow built-in system variables')) 'Advanced settings are localized and moved into Settings' Go 'About';WaitFor {$null -ne (Id 'SourceCodeLink')} 'About page missing' - Assert ((Id 'VersionText').Current.Name -eq $expected) 'About and sidebar display the same assembly version' + Assert ((Id 'VersionText').Current.Name -eq $expected) 'About retains the product version and build number' Assert ($null -ne (Find $window 'Windows environment variable and program alias manager')) 'About subtitle is localized' Assert ((Id 'SourceCodeLink').Current.HelpText -eq 'Napreth/PathMan' -and $null -ne (Id 'CheckUpdatesButton') -and (Id 'LicenseLink').Current.HelpText -eq 'MIT license') 'About displays repository, updates and MIT license rows' Assert ($null -eq (Id 'AdvancedSettingsLink')) 'About no longer contains advanced settings' diff --git a/tests/limited-process.cs b/tests/limited-process.cs new file mode 100644 index 0000000..062705b --- /dev/null +++ b/tests/limited-process.cs @@ -0,0 +1,76 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; + +// Launch the login-monitor tests with the same SID but a filtered, medium token. +// S4U batch logons can retain administrator groups even with RunLevel=Limited. +internal static class LimitedProcess +{ + [StructLayout(LayoutKind.Sequential)] private struct SidAttributes {public IntPtr Sid;public uint Attributes;} + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] private struct StartupInfo + {public int Size;public string Reserved,Desktop,Title;public int X,Y,Width,Height,CharsX,CharsY,Fill,Flags;public short Show,ReservedSize;public IntPtr ReservedBytes,Input,Output,Error;} + [StructLayout(LayoutKind.Sequential)] private struct ProcessInfo {public IntPtr Process,Thread;public uint ProcessId,ThreadId;} + [StructLayout(LayoutKind.Sequential)] private struct SecurityAttributes {public int Size;public IntPtr Descriptor;public int Inherit;} + [DllImport("kernel32.dll")] private static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] private static extern IntPtr GetStdHandle(int kind); + [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll")] private static extern uint WaitForSingleObject(IntPtr handle,uint timeout); + [DllImport("kernel32.dll")] private static extern bool GetExitCodeProcess(IntPtr handle,out int code); + [DllImport("kernel32.dll")] private static extern IntPtr LocalFree(IntPtr memory); + [DllImport("user32.dll")] private static extern IntPtr GetProcessWindowStation(); + [DllImport("user32.dll",SetLastError=true)] private static extern bool SetProcessWindowStation(IntPtr station); + [DllImport("user32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern IntPtr CreateWindowStation(string name,uint flags,uint access,ref SecurityAttributes security); + [DllImport("user32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern IntPtr CreateDesktop(string name,IntPtr device,IntPtr mode,uint flags,uint access,ref SecurityAttributes security); + [DllImport("user32.dll")] private static extern bool CloseWindowStation(IntPtr station); + [DllImport("user32.dll")] private static extern bool CloseDesktop(IntPtr desktop); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor(string text,uint revision,out IntPtr descriptor,IntPtr size); + [DllImport("advapi32.dll",SetLastError=true)] private static extern bool GetSecurityDescriptorDacl(IntPtr descriptor,out bool present,out IntPtr dacl,out bool defaulted); + [DllImport("advapi32.dll",SetLastError=true)] private static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] private static extern bool CreateRestrictedToken(IntPtr token,uint flags,uint disableCount,ref SidAttributes disabled,uint deleteCount,IntPtr deleted,uint restrictCount,IntPtr restricted,out IntPtr result); + [DllImport("advapi32.dll",SetLastError=true)] private static extern bool SetTokenInformation(IntPtr token,int type,ref SidAttributes information,int length); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern bool CreateProcessAsUser(IntPtr token,string application,StringBuilder command,IntPtr processSecurity,IntPtr threadSecurity,bool inherit,uint flags,IntPtr environment,string directory,ref StartupInfo startup,out ProcessInfo process); + private static IntPtr Sid(string value) + { + var sid=new SecurityIdentifier(value);var bytes=new byte[sid.BinaryLength];sid.GetBinaryForm(bytes,0); + var memory=Marshal.AllocHGlobal(bytes.Length);Marshal.Copy(bytes,0,memory,bytes.Length);return memory; + } + private static string Quote(string value) {return "\""+System.Text.RegularExpressions.Regex.Replace(value,@"(\\*)(""|$)",m=>m.Groups[1].Value+m.Groups[1].Value+(m.Groups[2].Value.Length==0 ? "" : "\\\""))+"\"";} + public static int Main(string[] args) + { + IntPtr original=IntPtr.Zero,limited=IntPtr.Zero,admins=IntPtr.Zero,medium=IntPtr.Zero; + IntPtr station=IntPtr.Zero,desktop=IntPtr.Zero,descriptor=IntPtr.Zero; + try + { + if(args.Length==0)return 2; + if(!OpenProcessToken(GetCurrentProcess(),0x8B,out original))throw new Win32Exception(Marshal.GetLastWin32Error(),"Open process token"); + admins=Sid("S-1-5-32-544");var disabled=new SidAttributes {Sid=admins}; + if(!CreateRestrictedToken(original,1,1,ref disabled,0,IntPtr.Zero,0,IntPtr.Zero,out limited))throw new Win32Exception(Marshal.GetLastWin32Error(),"Filter administrator token"); + medium=Sid("S-1-16-8192");var label=new SidAttributes {Sid=medium,Attributes=0x20}; + if(!SetTokenInformation(limited,25,ref label,Marshal.SizeOf(typeof(SidAttributes))))throw new Win32Exception(Marshal.GetLastWin32Error(),"Set medium integrity"); + // PowerShell Direct's private desktop only permits elevated tokens. + // Give this test its own medium-integrity desktop, away from the UI. + var stationName="PathMan.Test."+Guid.NewGuid().ToString("N"); + var sid=WindowsIdentity.GetCurrent().User.Value; + if(!ConvertStringSecurityDescriptorToSecurityDescriptor("D:(A;;GA;;;"+sid+")(A;;GA;;;SY)(A;;GA;;;BA)S:(ML;;NW;;;ME)",1,out descriptor,IntPtr.Zero))throw new Win32Exception(); + bool present,defaulted;IntPtr dacl; + if(!GetSecurityDescriptorDacl(descriptor,out present,out dacl,out defaulted))throw new Win32Exception(); + var defaults=new SidAttributes {Sid=dacl}; + if(!SetTokenInformation(limited,6,ref defaults,IntPtr.Size))throw new Win32Exception(Marshal.GetLastWin32Error(),"Set token default permissions"); + var security=new SecurityAttributes {Size=Marshal.SizeOf(typeof(SecurityAttributes)),Descriptor=descriptor}; + station=CreateWindowStation(stationName,0,0xF037F,ref security);if(station==IntPtr.Zero)throw new Win32Exception(); + var previous=GetProcessWindowStation(); + if(!SetProcessWindowStation(station))throw new Win32Exception(); + try {desktop=CreateDesktop("Default",IntPtr.Zero,IntPtr.Zero,0,0xF01FF,ref security);if(desktop==IntPtr.Zero)throw new Win32Exception();} + finally {SetProcessWindowStation(previous);} + var command=new StringBuilder();foreach(var argument in args)command.Append(Quote(argument)).Append(' '); + var startup=new StartupInfo {Size=Marshal.SizeOf(typeof(StartupInfo)),Desktop=stationName+"\\Default",Flags=0x100,Input=GetStdHandle(-10),Output=GetStdHandle(-11),Error=GetStdHandle(-12)};ProcessInfo child; + if(!CreateProcessAsUser(limited,args[0],command,IntPtr.Zero,IntPtr.Zero,true,0x08000000,IntPtr.Zero,null,ref startup,out child))throw new Win32Exception(Marshal.GetLastWin32Error(),"Launch limited process"); + try {if(WaitForSingleObject(child.Process,120000)!=0)throw new TimeoutException("Limited test process timed out.");int code;if(!GetExitCodeProcess(child.Process,out code))throw new Win32Exception();return code;} + finally {CloseHandle(child.Thread);CloseHandle(child.Process);} + } + catch(Exception error){Console.Error.WriteLine(error);return 1;} + finally {if(desktop!=IntPtr.Zero)CloseDesktop(desktop);if(station!=IntPtr.Zero)CloseWindowStation(station);if(descriptor!=IntPtr.Zero)LocalFree(descriptor);if(limited!=IntPtr.Zero)CloseHandle(limited);if(original!=IntPtr.Zero)CloseHandle(original);if(admins!=IntPtr.Zero)Marshal.FreeHGlobal(admins);if(medium!=IntPtr.Zero)Marshal.FreeHGlobal(medium);} + } +} diff --git a/tests/origin-task.ps1 b/tests/origin-task.ps1 new file mode 100644 index 0000000..262785a --- /dev/null +++ b/tests/origin-task.ps1 @@ -0,0 +1,108 @@ +[CmdletBinding()] +param( + [string]$AppDirectory=(Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts/publish/win-x64/SelfContained'), + [string]$ResultDirectory=(Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts/origin-task'), + [switch]$UserPhase, + [switch]$MissingTask +) +$ErrorActionPreference='Stop' +$AppDirectory=[IO.Path]::GetFullPath($AppDirectory).TrimEnd('\') +$ResultDirectory=[IO.Path]::GetFullPath($ResultDirectory) +if($env:GITHUB_ACTIONS -ne 'true' -and (Get-CimInstance Win32_ComputerSystem).Model -notmatch 'Virtual Machine'){throw 'Use a disposable Windows VM or GitHub Actions runner.'} +$service=Join-Path $AppDirectory 'internal/PathMan.Service.exe' +$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$hash=[Security.Cryptography.SHA256]::Create() +try{$id=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($service.ToUpperInvariant())))).Replace('-','').Substring(0,24)}finally{$hash.Dispose()} +$taskName='PathMan.Origins.'+$id+'.'+$sid +$admin=([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +$checks=[Collections.Generic.List[string]]::new() +function Assert($condition,[string]$message){if(!$condition){throw $message};$checks.Add($message)} +function WaitFor([scriptblock]$condition,[string]$message){for($i=0;$i -lt 200;$i++){if(& $condition){return};Start-Sleep -Milliseconds 150};throw $message} +function RunService([string[]]$arguments){$p=Start-Process $service -ArgumentList $arguments -WindowStyle Hidden -PassThru -Wait;return $p.ExitCode} +New-Item -ItemType Directory -Force -Path $ResultDirectory|Out-Null +if($UserPhase){ + $result=Join-Path $ResultDirectory $(if($MissingTask){'missing-task.json'}else{'user.json'}) + $dataRoot=Join-Path $ResultDirectory 'data';New-Item -ItemType Directory -Force -Path $dataRoot|Out-Null + $config=Join-Path $dataRoot 'environment.json' + $run=[Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Microsoft\Windows\CurrentVersion\Run',$true) + $old=$run.GetValue('PathMan',$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $kind=if($null -ne $old){$run.GetValueKind('PathMan')}else{$null} + $probeName='PATHMAN_ORIGIN_TEST_'+[guid]::NewGuid().ToString('N') + $process=$null + function State{Get-Content -LiteralPath $config -Raw|ConvertFrom-Json} + function Configure([bool]$monitoring,[bool]$tracking){$state=State;$state.monitoringEnabled=$monitoring;$state.trackChangeOrigins=$tracking;$state.originTrackingRequest=[guid]::NewGuid().ToString('N');$state|ConvertTo-Json -Depth 40|Set-Content -LiteralPath $config -Encoding UTF8} + function Launch{$script:process=Start-Process $service -ArgumentList @('--data-root',('"'+$dataRoot+'"')) -WindowStyle Hidden -PassThru} + try{ + Assert (!$admin) 'Background launch runs with a limited user token' + @{groups=@(@{id='paths';name='Paths';rule='paths'},@{id='system';name='System';rule='builtin'},@{id='other';name='Other';rule='fallback'},@{id='external';name='External';rule='external';requiresMonitoring=$true});preferences=@{language='en-US';autoCheckUpdates=$false};monitoringEnabled=$true;trackChangeOrigins=$true;originTrackingRequest=[guid]::NewGuid().ToString('N')}|ConvertTo-Json -Depth 12|Set-Content -LiteralPath $config -Encoding UTF8 + $run.SetValue('PathMan',('"'+$service+'"')) + Launch + if($MissingTask){ + WaitFor {![string]::IsNullOrEmpty((State).originTrackingMessage)} 'Missing authorization was not reported' + Assert (!(State).originTrackingReady -and !$process.HasExited) 'A missing task leaves ordinary monitoring running and reports an authorization error' + }else{ + WaitFor {(State).originTrackingReady} 'Scheduled origin tracking did not become ready' + Assert ((State).originTrackingReady) 'A limited monitor starts the privileged collector without interactive elevation' + $scheduler=New-Object -ComObject Schedule.Service;$scheduler.Connect();$folder=$scheduler.GetFolder('\');$task=$folder.GetTask($taskName) + $denied=$false;try{$task.Enabled=$false}catch{$denied=$true};Assert $denied 'The desktop user cannot change the privileged task' + $denied=$false;try{$stream=[IO.File]::Open($task.Definition.Actions.Item(1).Path,'Open','Write');$stream.Dispose()}catch [UnauthorizedAccessException]{$denied=$true};Assert $denied 'The desktop user cannot overwrite the collector executable' + $key=[Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment',$true) + try{$key.SetValue($probeName,'origin-test')}finally{$key.Dispose()} + WaitFor {@((State).changes|Where-Object {$_.name -eq $probeName -and @($_.origins|Where-Object processId -eq $PID).Count -gt 0}).Count -gt 0} 'The scheduled collector did not identify the actual registry writer' + Assert $true 'The SYSTEM collector reports the correct desktop user registry and writer PID' + Configure $true $false;WaitFor {!(State).originTrackingReady} 'Disabling tracking did not stop collection' + Configure $true $true;WaitFor {(State).originTrackingReady} 'Reenabling tracking did not reuse the saved task' + Assert $true 'Origin tracking can stop and restart without another administrator authorization' + $hash=[Security.Cryptography.SHA256]::Create() + try{$instance=([BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($AppDirectory.ToUpperInvariant())))).Replace('-','').Substring(0,16)}finally{$hash.Dispose()} + $stop=[Threading.EventWaitHandle]::OpenExisting(('Local\PathMan.'+$sid+'.'+$instance+'.Stop')) + try{$null=$stop.Set()}finally{$stop.Dispose()} + WaitFor {$process.HasExited} 'The background did not stop' + Launch;WaitFor {(State).originTrackingReady} 'The next background launch did not reuse the saved authorization' + Assert $true 'A subsequent background launch reuses the task without a UAC prompt' + } + Assert (!(Get-Process consent -ErrorAction SilentlyContinue)) 'No UAC consent process is left waiting' + @{Passed=$true;Checks=$checks.ToArray()}|ConvertTo-Json -Depth 6|Set-Content -LiteralPath $result -Encoding UTF8 + }catch{@{Passed=$false;Error=$_.Exception.Message;Trace=$_.ScriptStackTrace;State=$(if(Test-Path $config){State});Checks=$checks.ToArray()}|ConvertTo-Json -Depth 40|Set-Content -LiteralPath $result -Encoding UTF8;throw} + finally{ + if(Test-Path $config){Configure $false $false} + if($process -and !$process.HasExited){if(!$process.WaitForExit(10000)){$process.Kill()}} + if($null -eq $old){$run.DeleteValue('PathMan',$false)}else{$run.SetValue('PathMan',$old,$kind)};$run.Dispose() + $key=[Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment',$true);try{$key.DeleteValue($probeName,$false)}finally{$key.Dispose()} + } + return +} +if(!$admin){throw 'The setup phase requires an administrator token.'} +$acl=Get-Acl -LiteralPath $ResultDirectory +$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),[Security.AccessControl.FileSystemRights]::Modify,[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow)) +Set-Acl -LiteralPath $ResultDirectory -AclObject $acl +$launcher=Join-Path $ResultDirectory 'limited-process.exe' +$compiler=Join-Path $env:WINDIR 'Microsoft.NET/Framework64/v4.0.30319/csc.exe' +& $compiler /nologo /out:$launcher (Join-Path $PSScriptRoot 'limited-process.cs') +if($LASTEXITCODE -ne 0){throw 'Failed to compile the limited-token test launcher.'} +function RunUserPhase([switch]$Missing){ + $file=Join-Path $ResultDirectory $(if($Missing){'missing-task.json'}else{'user.json'}) + if(Test-Path -LiteralPath $file){Remove-Item -LiteralPath $file -Force} + $arguments='"'+(Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe')+'" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$PSCommandPath+'" -AppDirectory "'+$AppDirectory+'" -ResultDirectory "'+$ResultDirectory+'" -UserPhase'+$(if($Missing){' -MissingTask'}) + $process=Start-Process $launcher -ArgumentList $arguments -WindowStyle Hidden -PassThru -Wait -RedirectStandardError (Join-Path $ResultDirectory 'launcher.stderr.log') -RedirectStandardOutput (Join-Path $ResultDirectory 'launcher.stdout.log') + if($process.ExitCode -ne 0){throw ('Limited process failed ('+$process.ExitCode+'): '+(Get-Content (Join-Path $ResultDirectory 'launcher.stderr.log') -Raw))} + WaitFor {Test-Path -LiteralPath $file} 'Limited test process did not report its result' + $result=Get-Content -LiteralPath $file -Raw|ConvertFrom-Json + if(!$result.Passed){throw ($result|ConvertTo-Json -Depth 40)} + Assert ($process.ExitCode -eq 0) 'The limited test process completed successfully' + $checks.AddRange([string[]]$result.Checks) +} +try{ + Assert ((RunService @('--register-origin-task',$sid)) -eq 0) 'One-time administrator setup registers the collector' + $task=Get-ScheduledTask -TaskName $taskName + Assert ($task.Principal.UserId -in @('SYSTEM','S-1-5-18') -and $task.Triggers.Count -eq 0) 'The SYSTEM task runs only on demand from the ordinary login monitor' + Assert ($task.Actions.Execute.StartsWith([Environment]::GetFolderPath('ProgramFiles')+'\') -and $task.Actions.Execute -ne $service) 'The privileged executable is copied into a protected location' + RunUserPhase + Assert ((RunService @('--remove-origin-tasks')) -eq 0) 'Uninstall maintenance removes this installation task and protected files' + Assert ($null -eq (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) -and !(Test-Path -LiteralPath $task.Actions.Execute)) 'No privileged task or collector executable remains after cleanup' + RunUserPhase -Missing + @{Passed=$true;Checks=$checks.ToArray()}|ConvertTo-Json -Depth 6|Set-Content -LiteralPath (Join-Path $ResultDirectory 'results.json') -Encoding UTF8 + Write-Host 'PASS: one-time authorization, limited-user login/restart, ETW origins, protected task/files, no automatic UAC fallback and uninstall cleanup.' +}finally{ + $null=RunService @('--remove-origin-tasks') +} From bbc7ce27616ac13b775950f35daa7f1152c0f9df Mon Sep 17 00:00:00 2001 From: Napreth Date: Thu, 5 Nov 2026 21:49:23 +0800 Subject: [PATCH 2/6] Add unified monitoring with per-user isolation --- .github/workflows/build.yml | 3 + README.md | 4 +- src/Pathman.Core/BackgroundProcess.cs | 2 + .../EnvironmentMonitor.cs | 26 +-- .../EnvironmentWorkspace.cs | 6 +- .../WindowsEnvironmentVariableEditor.cs | 5 +- .../WindowsEnvironmentVariableReader.cs | 2 +- src/Pathman.Core/GlobalMonitoring.cs | 158 ++++++++++++++++++ src/Pathman.Core/Localization/Strings.resx | 7 + .../Localization/Strings.zh-CN.resx | 7 + src/Pathman.Core/OriginTrackingTask.cs | 40 ++++- src/Pathman.Core/StartupManager.cs | 4 +- src/Pathman.Core/WindowsUserContext.cs | 63 +++++++ src/Pathman.Gui/MainWindow.xaml.cs | 3 +- src/Pathman.Gui/Views/MonitorView.xaml | 15 +- src/Pathman.Gui/Views/MonitorView.xaml.cs | 43 ++++- src/Pathman.Service/LoggedOnUsers.cs | 92 ++++++++++ src/Pathman.Service/Program.cs | 21 ++- src/Pathman.Service/RegistryObjectNames.cs | 11 +- .../RegistryOriginCollector.cs | 89 ++++++++++ src/Pathman.Service/RegistryOriginTracker.cs | 84 ++-------- src/Pathman.Service/SystemMonitorHost.cs | 157 +++++++++++++++++ tests/Pathman.Core.Tests/MonitoringTests.cs | 7 + tests/global-monitor.ps1 | 55 ++++++ tests/origin-task.ps1 | 5 +- 25 files changed, 792 insertions(+), 117 deletions(-) create mode 100644 src/Pathman.Core/GlobalMonitoring.cs create mode 100644 src/Pathman.Core/WindowsUserContext.cs create mode 100644 src/Pathman.Service/LoggedOnUsers.cs create mode 100644 src/Pathman.Service/RegistryOriginCollector.cs create mode 100644 src/Pathman.Service/SystemMonitorHost.cs create mode 100644 tests/global-monitor.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48df8cb..8e4ef01 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -103,6 +103,9 @@ jobs: - name: Test origin tracking authorization if: matrix.arch == 'x64' && !env.ACT run: ./tests/origin-task.ps1 + - name: Test unified monitoring service + if: matrix.arch == 'x64' && !env.ACT + run: ./tests/global-monitor.ps1 - name: Accept final installers if: ${{ !env.ACT }} run: ./tests/installer-acceptance.ps1 -Architecture $env:PATHMAN_ARCH -Version $env:PATHMAN_VERSION diff --git a/README.md b/README.md index b68159b..5b83fa7 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,9 @@ PATH 不可作为版本目标。内置系统变量默认不可选,可在“设 用户和系统范围分别提供外部变量归类、PATH 去重与迁移选项,也可排除系统环境编辑器或 PowerToys 的修改。用户外部路径迁入 `EXTERNAL_USER_PATH`,系统外部路径迁入 `EXTERNAL_PATH`。 -“精确识别修改来源”首次启用时需要管理员授权,之后由计划任务启动只读的来源采集程序,登录时不会重复弹出 UAC。监控主进程仍以当前用户权限运行。来源无法确认时保留原值,系统 PATH 自动整理仍需单独授权。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。 +“精确识别修改来源”首次启用时需要管理员授权,之后由计划任务启动只读的来源采集程序,登录时不会重复弹出 UAC。管理员本人的采集任务使用其提权令牌,跨用户授权的采集任务使用 SYSTEM。监控主进程仍以当前用户权限运行。来源无法确认时保留原值,个人模式下的系统 PATH 自动整理仍需单独授权。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。 + +为所有用户安装时,“系统变量”下面还提供“全局设置”。开启“为所有用户开启统一设置”后,由一个自动启动的 Windows 服务承担监控与来源采集,个人后台程序退出;个人的系统 PATH 整理选项停用,其保存的值保留。全局设置可分别启用非管理员用户的精确来源识别和系统 PATH 自动整理,配置、机器级记录及备份保存在受保护的 `%ProgramData%\PathMan\monitor`。服务按用户令牌处理各自的用户变量与个人配置。关闭统一设置后,恢复符合个人后台运行和登录自启条件的后台程序。仅为自己安装时不显示全局设置。 ### 设置与关于 diff --git a/src/Pathman.Core/BackgroundProcess.cs b/src/Pathman.Core/BackgroundProcess.cs index da3d984..92baafc 100644 --- a/src/Pathman.Core/BackgroundProcess.cs +++ b/src/Pathman.Core/BackgroundProcess.cs @@ -8,6 +8,7 @@ namespace Pathman.Core; public sealed class BackgroundProcess(PathmanLayout layout) { + public static Mutex OwnerMutex(string sid,string installation)=>WindowsUserContext.WorkspaceMutex(@"Global\PathMan.MonitorOwner."+sid); // Per-user, per-installation names prevent another installation from stopping this process. public string InstanceName { @@ -32,6 +33,7 @@ public bool IsRunning public void Start() { + if(new GlobalMonitoring(layout).IsEnabled) return; if (IsRunning) return; var state=new EnvironmentWorkspaceStore(layout.DataDirectory).Read(); if(!StartupManager.CanRun(state,new StartupManager(layout).IsEnabled)) diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs index ceb2330..dd9656a 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs @@ -7,7 +7,7 @@ namespace Pathman.Core.EnvironmentVariables; -public sealed class EnvironmentMonitor(EnvironmentWorkspaceStore store) +public sealed class EnvironmentMonitor(EnvironmentWorkspaceStore store,VariableScope? managedScope=null,IReadOnlyDictionary? runtime=null) { private readonly HashSet failedPlans=[]; public static IReadOnlyList ReadAll() @@ -15,31 +15,33 @@ public static IReadOnlyList ReadAll() var reader = new WindowsEnvironmentVariableReader(); return reader.Read(VariableScope.User).Concat(reader.Read(VariableScope.Machine)).ToArray(); } - public void SetEnabled(bool enabled) => store.Update(state => + private IReadOnlyList ReadCurrent()=>managedScope==VariableScope.Machine ? new WindowsEnvironmentVariableReader().Read(VariableScope.Machine) : ReadAll(); + public void SetEnabled(bool enabled,IReadOnlyList? captured=null) => store.Update(state => { state.MonitoringEnabled = enabled; - if (enabled) state.Snapshot = ReadAll().Select(Snapshot).ToList(); + if (enabled) state.Snapshot = (captured ?? ReadCurrent()).Select(Snapshot).ToList(); }); - public void Poll(IEnvironmentChangeOrigins? origins=null) + public bool Poll(IEnvironmentChangeOrigins? origins=null,IReadOnlyList? captured=null) { var paused=false; store.Inspect(state=> { if(!state.MonitoringEnabled) return false; if(state.RegistryWriteInProgressUntil>DateTime.UtcNow) {paused=true;return false;} - var current=ReadAll(); + var current=captured ?? ReadCurrent(); var before=state.Snapshot.ToArray(); var changed=Compare(state,current,origins); - return PreparePlans(state,before,current,changed,origins:origins) || changed; + return PreparePlans(state,before,current,changed,runtime:runtime,origins:origins,managedScope:managedScope) || changed; }); - if(paused) return; + if(paused) return false; var saved=store.Read(); - if(!saved.MonitoringEnabled || !saved.AnyPathOrganizationEnabled) return; + if(!saved.MonitoringEnabled || !saved.AnyPathOrganizationEnabled) return false; + var applied=false; using var identity=WindowsIdentity.GetCurrent(); var administrator=new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); foreach(var plan in saved.PendingPathOrganizations) { - if(!saved.IsPathOrganizationEnabled(plan.Scope) || RequiresOrigin(saved,plan.Scope) && origins?.IsReady!=true) continue; + if(managedScope is {} selected && plan.Scope!=selected || !saved.IsPathOrganizationEnabled(plan.Scope) || RequiresOrigin(saved,plan.Scope) && origins?.IsReady!=true) continue; if(failedPlans.Contains(plan.Id)) continue; if(plan.Scope==VariableScope.Machine && !administrator) continue; try @@ -51,21 +53,23 @@ public void Poll(IEnvironmentChangeOrigins? origins=null) throw new IOException(L.Get("监控设置或待整理项目已改变,本次整理已跳过。")); }); Complete(store,plan); + applied=true; } catch(Exception error) when(error is IOException or UnauthorizedAccessException or System.Security.SecurityException or ArgumentException) {failedPlans.Add(plan.Id);ReportError(L.Get("自动整理未完成:")+error.Message);} } failedPlans.RemoveWhere(id=>!saved.PendingPathOrganizations.Any(plan=>plan.Id==id)); + return applied; } internal static bool PreparePlans(EnvironmentWorkspace state,IReadOnlyList before, - IReadOnlyList current,bool changed,IReadOnlyDictionary? runtime=null,IEnvironmentChangeOrigins? origins=null) + IReadOnlyList current,bool changed,IReadOnlyDictionary? runtime=null,IEnvironmentChangeOrigins? origins=null,VariableScope? managedScope=null) { if(!state.AnyPathOrganizationEnabled) {var had=state.PendingPathOrganizations.Count>0;state.PendingPathOrganizations.Clear();return had;} var updated=false; foreach(var scope in new[] {VariableScope.User,VariableScope.Machine}) { - if(!state.IsPathOrganizationEnabled(scope)) + if(managedScope is {} selected && scope!=selected || !state.IsPathOrganizationEnabled(scope)) {updated|=state.PendingPathOrganizations.RemoveAll(item=>item.Scope==scope)>0;continue;} var pending=state.PendingPathOrganizations.FirstOrDefault(item=>item.Scope==scope); var old=before.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals("Path",StringComparison.OrdinalIgnoreCase)); diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs index 9dd030b..b40637e 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs @@ -171,7 +171,7 @@ public sealed class EnvironmentWorkspaceStore public EnvironmentWorkspaceStore(string dataDirectory) { FilePath = Path.Combine(Path.GetFullPath(dataDirectory), "environment.json"); - mutexName = @"Local\PathMan.Environment." + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(FilePath.ToUpperInvariant())))[..24]; + mutexName = @"Global\PathMan.Environment." + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(FilePath.ToUpperInvariant())))[..24]; } internal static T ReadDefault(string name) => JsonSerializer.Deserialize(typeof(EnvironmentWorkspaceStore).Assembly .GetManifestResourceStream($"Pathman.Core.EnvironmentVariables.Defaults.{name}.json")!, JsonOptions)!; @@ -203,7 +203,7 @@ private EnvironmentWorkspace Load() } private T Locked(Func action) { - using var mutex = new Mutex(false, mutexName); + using var mutex = WindowsUserContext.WorkspaceMutex(mutexName); try { if (!mutex.WaitOne(TimeSpan.FromSeconds(15))) throw new IOException(L.Get("环境变量配置正在使用中,请稍后重试。")); } catch (AbandonedMutexException) { } try { return action(); } finally { mutex.ReleaseMutex(); } @@ -212,6 +212,7 @@ public EnvironmentWorkspace Read() => Locked(()=> { var state=Load();if(EnvironmentMonitorNotices.Reconcile(state)) 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; @@ -220,6 +221,7 @@ internal void Inspect(Func change) => Locked(() => { var state = Load(); var reconciled=EnvironmentMonitorNotices.Reconcile(state); if (change(state) || reconciled) Save(state); return true; }); + public void InspectForService(Func change)=>Inspect(change); private void Save(EnvironmentWorkspace state) { state.Revision = Guid.NewGuid().ToString("N"); diff --git a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs index 5827658..3b1baed 100644 --- a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs +++ b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs @@ -32,7 +32,7 @@ public static void Apply(EnvironmentMutation mutation, EnvironmentWorkspaceStore if (mutation.Scope is not (VariableScope.User or VariableScope.Machine)) throw new ArgumentOutOfRangeException(nameof(mutation)); store.Update(state => { - using var key = mutation.Scope == VariableScope.User ? Registry.CurrentUser.CreateSubKey("Environment", writable: true) : + using var key = mutation.Scope == VariableScope.User ? WindowsUserContext.EnvironmentKey(true) ?? throw new IOException("Cannot open user environment.") : Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", writable: true) ?? throw new IOException(L.Get("找不到系统环境变量注册表项。")); ApplyKey(key, mutation); @@ -54,7 +54,7 @@ public static void ApplyBatch(IReadOnlyList mutations, IRea { var writable = requiredScope == scope; keys[requiredScope] = requiredScope == VariableScope.User - ? Registry.CurrentUser.CreateSubKey("Environment", writable) + ? WindowsUserContext.EnvironmentKey(writable) ?? throw new IOException("Cannot open user environment.") : Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", writable) ?? throw new IOException(L.Get("找不到环境变量注册表项。")); } @@ -187,4 +187,5 @@ private static void RecordOwnChange(EnvironmentWorkspace state, EnvironmentMutat [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam, string lParam, uint flags, uint timeout, out UIntPtr result); private static void Broadcast() => SendMessageTimeout(new IntPtr(0xffff), 0x1a, UIntPtr.Zero, "Environment", 2, 200, out _); + public static void NotifyEnvironmentChanged()=>Broadcast(); } diff --git a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableReader.cs b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableReader.cs index f00b3fe..cd693c3 100644 --- a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableReader.cs +++ b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableReader.cs @@ -11,7 +11,7 @@ public IReadOnlyList Read(VariableScope scope) // environment inherited by this process. Reading needs no elevation. using var key = scope switch { - VariableScope.User => Registry.CurrentUser.OpenSubKey("Environment", writable: false), + VariableScope.User => WindowsUserContext.EnvironmentKey(false), VariableScope.Machine => Registry.LocalMachine.OpenSubKey( @"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", writable: false), _ => throw new ArgumentOutOfRangeException(nameof(scope)) diff --git a/src/Pathman.Core/GlobalMonitoring.cs b/src/Pathman.Core/GlobalMonitoring.cs new file mode 100644 index 0000000..54f2ff9 --- /dev/null +++ b/src/Pathman.Core/GlobalMonitoring.cs @@ -0,0 +1,158 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Security.Principal; +using System.Security.AccessControl; +using System.Text; +using System.Text.Json; +using Pathman.Core.EnvironmentVariables; + +namespace Pathman.Core; + +public sealed record GlobalMonitorSettings(bool Enabled=false,bool ProvideNonAdministratorOrigins=false,bool AutoArrangeMachinePath=false,string InstallationDirectory=""); + +public sealed class GlobalMonitoring(PathmanLayout layout) +{ + public const string ServiceName="PathMan.Monitor"; + public static string DataDirectory=>Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),"PathMan","monitor"); + public static string SettingsPath=>Path.Combine(DataDirectory,"settings.json"); + public static string MachineDirectory=>Path.Combine(DataDirectory,"machine"); + public static string StatusPath=>Path.Combine(DataDirectory,"status.json"); + public bool IsAllUsers + { + get + { + var file=Path.Combine(layout.InternalDirectory,"install.ini"); + return File.Exists(file) && File.ReadLines(file).Any(line=>line.Trim().Equals("Scope=AllUsers",StringComparison.OrdinalIgnoreCase)); + } + } + public GlobalMonitorSettings Read() + { + var settings=ReadSettings(); + return settings.InstallationDirectory.Equals(layout.InstallationDirectory,StringComparison.OrdinalIgnoreCase) ? settings : new(); + } + public static GlobalMonitorSettings ReadSettings()=>File.Exists(SettingsPath) + ? JsonSerializer.Deserialize(File.ReadAllText(SettingsPath),EnvironmentWorkspaceStore.JsonOptions) ?? new() : new(); + public bool IsEnabled=>ReadSettings().Enabled; + public void Configure(bool enabled,bool provideOrigins,bool organize) + { + if(!IsAllUsers) throw new IOException(L.Get("全局监控仅支持为所有用户安装。")); + var start=new ProcessStartInfo(layout.BackgroundExecutable) {UseShellExecute=true,Verb="runas",WindowStyle=ProcessWindowStyle.Hidden}; + foreach(var value in new[]{"--configure-global-monitor",enabled.ToString(),provideOrigins.ToString(),organize.ToString()}) start.ArgumentList.Add(value); + 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) + { + 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);}finally {mutex.ReleaseMutex();} + } + private void ConfigureLocked(bool enabled,bool provideOrigins,bool organize) + { + if(!IsAllUsers) throw new IOException(L.Get("全局监控仅支持为所有用户安装。")); + OriginTrackingTask.SecureDirectory(Path.GetDirectoryName(DataDirectory)!); + OriginTrackingTask.SecureDirectory(DataDirectory); + OriginTrackingTask.SecureDirectory(MachineDirectory); + SecureExistingData(DataDirectory); + var previous=ReadSettings(); + if(previous.Enabled && !previous.InstallationDirectory.Equals(layout.InstallationDirectory,StringComparison.OrdinalIgnoreCase)) throw new IOException("Another installation owns global monitoring."); + var updated=new GlobalMonitorSettings(enabled,provideOrigins,organize,layout.InstallationDirectory); + if(enabled) + { + var executable=Path.Combine(ProtectedDirectory,"PathMan.Service.exe"); + if(!File.Exists(executable) || !FileHash(executable).SequenceEqual(FileHash(layout.BackgroundExecutable))) + { + Stop();OriginTrackingTask.SecureDirectory(ProtectedDirectory); + foreach(var file in Directory.EnumerateFiles(layout.InternalDirectory)) + { + if(!Path.GetFileName(file).Equals("PathMan.Service.exe",StringComparison.OrdinalIgnoreCase) && Path.GetExtension(file) is not (".dll" or ".json")) continue; + if((File.GetAttributes(file)&FileAttributes.ReparsePoint)!=0) throw new IOException("Invalid service payload."); + for(var retry=0;;retry++) + { + try {File.Copy(file,Path.Combine(ProtectedDirectory,Path.GetFileName(file)),true);break;} + catch(IOException failure) when(retry<50 && (failure.HResult&0xffff) is 32 or 33) {Thread.Sleep(100);} + } + } + } + var command=$"\"{executable}\" --system-monitor"; + var exists=Sc("query",ServiceName)==0; + Checked(exists ? "config" : "create",ServiceName,"binPath=",command,"start=","auto","obj=","LocalSystem","DisplayName=","PathMan Monitor"); + // Restart automatically after a crash; an ordinary user cannot edit the service. + Checked("failure",ServiceName,"reset=","86400","actions=","restart/5000/restart/15000/restart/60000"); + } + WriteSettings(updated); + try + { + if(enabled) {var result=Sc("start",ServiceName);if(result is not (0 or 1056)) throw new Win32Exception(result);} + else {Stop();if(Sc("query",ServiceName)==0) Checked("config",ServiceName,"start=","demand");} + } + catch {WriteSettings(previous);throw;} + } + public void Maintain() + { + var state=Read(); + if(state.Enabled) ConfigureElevated(true,state.ProvideNonAdministratorOrigins,state.AutoArrangeMachinePath); + } + public void Remove() + { + var state=ReadSettings(); + if(!state.InstallationDirectory.Equals(layout.InstallationDirectory,StringComparison.OrdinalIgnoreCase)) return; + DemandAdministrator();Stop(); + if(Sc("query",ServiceName)==0) Checked("delete",ServiceName); + WriteSettings(state with {Enabled=false}); + // Delete only the fixed, protected executable directory owned by this service. + if(Directory.Exists(ProtectedDirectory)) + { + if((File.GetAttributes(ProtectedDirectory)&FileAttributes.ReparsePoint)!=0) throw new IOException("Invalid service cleanup directory."); + OriginTrackingTask.DeleteStoppedDirectory(ProtectedDirectory); + } + } + 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 status=ScOutput("query",ServiceName); + if(status.Code==1060 || status.Output.Contains("STOPPED",StringComparison.Ordinal)) return; + Thread.Sleep(100); + } + throw new IOException("Global monitor did not stop."); + } + private static byte[] FileHash(string path) {using var stream=File.OpenRead(path);return System.Security.Cryptography.SHA256.HashData(stream);} + private static void WriteSettings(GlobalMonitorSettings settings) + { + var temporary=SettingsPath+"."+Guid.NewGuid().ToString("N")+".tmp"; + File.WriteAllText(temporary,JsonSerializer.Serialize(settings,EnvironmentWorkspaceStore.JsonOptions),new UTF8Encoding(false)); + File.Move(temporary,SettingsPath,true); + } + private static void SecureExistingData(string directory) + { + foreach(var child in Directory.EnumerateFileSystemEntries(directory)) + { + var attributes=File.GetAttributes(child); + if((attributes&FileAttributes.ReparsePoint)!=0) throw new IOException("Global monitor data cannot contain reparse points."); + if((attributes&FileAttributes.Directory)!=0) {OriginTrackingTask.SecureDirectory(child);SecureExistingData(child);continue;} + var file=new FileInfo(child);var security=new FileSecurity(); + var admins=new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid,null); + security.SetOwner(admins);security.SetAccessRuleProtection(true,false); + foreach(var principal in new[]{admins,new SecurityIdentifier(WellKnownSidType.LocalSystemSid,null)}) security.AddAccessRule(new(principal,FileSystemRights.FullControl,AccessControlType.Allow)); + security.AddAccessRule(new(new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid,null),FileSystemRights.ReadAndExecute,AccessControlType.Allow)); + try {file.SetAccessControl(security);}catch(FileNotFoundException) { } // An atomic service write may have replaced this file. + } + } + private static int Sc(params string[] arguments)=>ScOutput(arguments).Code; + private static void Checked(params string[] arguments) {var result=ScOutput(arguments);if(result.Code!=0) throw new IOException(result.Output);} + private static (int Code,string Output) ScOutput(params string[] arguments) + { + var start=new ProcessStartInfo(Path.Combine(Environment.SystemDirectory,"sc.exe")) {UseShellExecute=false,CreateNoWindow=true,RedirectStandardOutput=true,RedirectStandardError=true}; + foreach(var argument in arguments) start.ArgumentList.Add(argument); + using var process=Process.Start(start) ?? throw new IOException("Cannot start the service controller."); + var output=process.StandardOutput.ReadToEndAsync();var error=process.StandardError.ReadToEndAsync(); + if(!process.WaitForExit(20000)) throw new IOException("Service controller timeout."); + return (process.ExitCode,output.GetAwaiter().GetResult()+error.GetAwaiter().GetResult()); + } + private static void DemandAdministrator() {if(!WindowsUserContext.IsElevatedAdministrator()) throw new Win32Exception(740);} +} diff --git a/src/Pathman.Core/Localization/Strings.resx b/src/Pathman.Core/Localization/Strings.resx index 7881001..3efac70 100644 --- a/src/Pathman.Core/Localization/Strings.resx +++ b/src/Pathman.Core/Localization/Strings.resx @@ -1233,4 +1233,11 @@ Go to version management This feature adds the command-line directory to PATH Receive prerelease updates + Global settings + Enable unified settings for all users + The per-user option to automatically organize the system PATH will be unavailable + Provide precise change origins for non-administrator users + Automatically organize the system PATH + Global monitoring requires an installation for all users. + Cannot configure the global monitoring service. diff --git a/src/Pathman.Core/Localization/Strings.zh-CN.resx b/src/Pathman.Core/Localization/Strings.zh-CN.resx index 5b6d6ab..5a04ddb 100644 --- a/src/Pathman.Core/Localization/Strings.zh-CN.resx +++ b/src/Pathman.Core/Localization/Strings.zh-CN.resx @@ -1233,4 +1233,11 @@ 前往版本管理 此功能将在PATH中添加命令行目录 接收测试版更新 + 全局设置 + 为所有用户开启统一设置 + 用户级的‘自动整理系统PATH环境变量’将不可用 + 为非管理员用户提供精确修改来源 + 自动整理系统PATH环境变量 + 全局监控仅支持为所有用户安装。 + 无法配置全局监控服务。 diff --git a/src/Pathman.Core/OriginTrackingTask.cs b/src/Pathman.Core/OriginTrackingTask.cs index cf37b81..e27adac 100644 --- a/src/Pathman.Core/OriginTrackingTask.cs +++ b/src/Pathman.Core/OriginTrackingTask.cs @@ -9,8 +9,8 @@ namespace Pathman.Core; -// Only the read-only collector runs as SYSTEM. The monitor retains the desktop -// user's token and HKCU. No task parameters select executable paths or commands. +// An administrator's own collector uses their elevated interactive token. +// Cross-user authorization uses SYSTEM for the fixed, read-only collector. public sealed class OriginTrackingTask(PathmanLayout layout) { private string InstallationId => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(layout.BackgroundExecutable.ToUpperInvariant())))[..24]; @@ -40,13 +40,19 @@ public bool IsRegistered dynamic? task=Find(Connect(),CurrentSid); if(task is null || !task.Enabled) return false; dynamic definition=task!.Definition; - return ((string)definition.Principal.UserId is "SYSTEM" or "S-1-5-18") && definition.Actions.Count==1 && + return ValidPrincipal((string)definition.Principal.UserId,CurrentSid) && definition.Principal.RunLevel==1 && definition.Actions.Count==1 && string.Equals((string)definition.Actions.Item(1).Path,Executable,StringComparison.OrdinalIgnoreCase) && definition.Actions.Item(1).Arguments==ArgumentsFor(CurrentSid) && File.Exists(Executable); } catch(COMException error) {throw new IOException(L.Get("无法访问来源识别计划任务。"),error);} } } + private static bool ValidPrincipal(string principal,string sid) + { + if(principal is "SYSTEM" or "S-1-5-18" || principal==sid) return true; + try {return ((SecurityIdentifier)new NTAccount(principal).Translate(typeof(SecurityIdentifier))).Value==sid;} + catch(IdentityNotMappedException) {return false;} + } public void EnsureRegistered() { if(IsRegistered && FileHash(Executable).SequenceEqual(FileHash(layout.BackgroundExecutable))) return; @@ -67,7 +73,8 @@ public void Register(string sid) { sid=new SecurityIdentifier(sid).Value; DemandAdministrator(); - dynamic folder=Connect();dynamic? old=Find(folder,sid);old?.Stop(0); + using var identity=WindowsIdentity.GetCurrent();var own=identity.User!.Value==sid; + dynamic folder=Connect();dynamic? old=Find(folder,sid);if(old is not null) StopTask(old); var target=DirectoryFor(sid); // Program Files is a trusted anchor even for a per-user installation. // Do not follow junctions or reuse a directory owned by an ordinary user. @@ -84,14 +91,14 @@ public void Register(string sid) XNamespace ns="http://schemas.microsoft.com/windows/2004/02/mit/task"; XElement Element(string name,object value)=>new(ns+name,value); var xml=new XDocument(new XElement(ns+"Task",new XAttribute("version","1.2"), - Element("Principals",new XElement(ns+"Principal",new XAttribute("id","Collector"),Element("UserId","S-1-5-18"),Element("RunLevel","HighestAvailable"))), + Element("Principals",new XElement(ns+"Principal",new XAttribute("id","Collector"),Element("UserId",own ? sid : "S-1-5-18"),own ? Element("LogonType","InteractiveToken") : null,Element("RunLevel","HighestAvailable"))), new XElement(ns+"Settings",Element("MultipleInstancesPolicy","Parallel"),Element("DisallowStartIfOnBatteries","false"),Element("StopIfGoingOnBatteries","false"), Element("AllowStartOnDemand","true"),Element("Enabled","true"),Element("ExecutionTimeLimit","PT0S")), new XElement(ns+"Actions",new XAttribute("Context","Collector"),new XElement(ns+"Exec",Element("Command",Path.Combine(target,"PathMan.Service.exe")),Element("Arguments",ArgumentsFor(sid)),Element("WorkingDirectory",target))))); // The desktop user may read/run the fixed task, but cannot edit it. - folder.RegisterTask(Prefix+sid,xml.ToString(),6,"SYSTEM",null,5,$"D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGX;;;{sid})"); + folder.RegisterTask(Prefix+sid,xml.ToString(),6,own ? sid : "SYSTEM",null,own ? 3 : 5,$"O:BAG:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGX;;;{sid})"); } - private static void SecureDirectory(string path) + internal static void SecureDirectory(string path) { var directory=Directory.CreateDirectory(path); if((directory.Attributes&FileAttributes.ReparsePoint)!=0) throw new IOException("Collector directories cannot be reparse points."); @@ -117,11 +124,26 @@ public void RemoveInstallation() if(!HasInstallationTasks() && !Directory.Exists(directory)) return; DemandAdministrator();dynamic folder=Connect(); foreach(dynamic task in folder.GetTasks(1)) - if(((string)task.Name).StartsWith(Prefix,StringComparison.Ordinal)) {task.Stop(0);folder.DeleteTask(task.Name,0);} + if(((string)task.Name).StartsWith(Prefix,StringComparison.Ordinal)) {StopTask(task);folder.DeleteTask(task.Name,0);} if(Directory.Exists(directory)) { if((File.GetAttributes(directory)&FileAttributes.ReparsePoint)!=0) throw new IOException("Invalid collector cleanup directory."); - Directory.Delete(directory,true); + DeleteStoppedDirectory(directory); + } + } + private static void StopTask(dynamic task) + { + task.Stop(0); + for(var retry=0;retry<100;retry++) {if(task.GetInstances(0).Count==0) return;Thread.Sleep(100);} + throw new IOException("Collector task did not stop."); + } + internal static void DeleteStoppedDirectory(string directory) + { + for(var retry=0;;retry++) + { + try {Directory.Delete(directory,true);return;} + catch(IOException failure) when(retry<100 && (failure.HResult&0xffff) is 5 or 32 or 33) {Thread.Sleep(100);} + catch(UnauthorizedAccessException) when(retry<100) {Thread.Sleep(100);} } } private static void DemandAdministrator() diff --git a/src/Pathman.Core/StartupManager.cs b/src/Pathman.Core/StartupManager.cs index 5b3da3f..c13ea32 100644 --- a/src/Pathman.Core/StartupManager.cs +++ b/src/Pathman.Core/StartupManager.cs @@ -13,7 +13,7 @@ public bool IsEnabled { get { - using var key = Registry.CurrentUser.OpenSubKey(RunKey); + using var user=WindowsUserContext.OpenRegistry();using var key = user.OpenSubKey(RunKey); return string.Equals(key?.GetValue(ValueName) as string, Command, StringComparison.OrdinalIgnoreCase); } } @@ -24,7 +24,7 @@ public void SetEnabled(bool enabled) throw new IOException(L.Get("请先启用“在后台运行监控程序”。")); if (enabled && !File.Exists(layout.BackgroundExecutable)) throw new FileNotFoundException(L.Get("后台程序不存在,请先构建或安装 PathMan。"), layout.BackgroundExecutable); - using var key = Registry.CurrentUser.CreateSubKey(RunKey, writable: true); + using var user=WindowsUserContext.OpenRegistry(true);using var key = user.CreateSubKey(RunKey, writable: true); if (enabled) key.SetValue(ValueName, Command, RegistryValueKind.String); else if (IsEnabled) key.DeleteValue(ValueName, throwOnMissingValue: false); } diff --git a/src/Pathman.Core/WindowsUserContext.cs b/src/Pathman.Core/WindowsUserContext.cs new file mode 100644 index 0000000..5583220 --- /dev/null +++ b/src/Pathman.Core/WindowsUserContext.cs @@ -0,0 +1,63 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +namespace Pathman.Core; + +public static class WindowsUserContext +{ + public static RegistryKey OpenRegistry(bool writable=false) + { + var error=RegOpenCurrentUser(writable ? 0x2001f : 0x20019,out var key); + if(error!=0) throw new Win32Exception(error); + return RegistryKey.FromHandle(key); + } + public static RegistryKey? EnvironmentKey(bool writable) + {using var user=OpenRegistry(writable);return writable ? user.CreateSubKey("Environment",true) : user.OpenSubKey("Environment");} + public static bool IsElevatedAdministrator() + { + using var identity=WindowsIdentity.GetCurrent(); + return IsElevatedAdministrator(identity); + } + public static bool IsElevatedAdministrator(WindowsIdentity identity) + { + if(!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) return false; + var data=Marshal.AllocHGlobal(256); + try + { + if(!GetTokenInformation(identity.Token,25,data,256,out _)) return false; + var sid=new SecurityIdentifier(Marshal.ReadIntPtr(data)); + return int.Parse(sid.Value.Split('-')[^1],System.Globalization.CultureInfo.InvariantCulture)>=12288; + } + finally {Marshal.FreeHGlobal(data);} + } + public static bool IsAdministratorAccount() + { + using var identity=WindowsIdentity.GetCurrent(); + if(new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) return true; + var data=Marshal.AllocHGlobal(4); + try {return GetTokenInformation(identity.Token,18,data,4,out _) && Marshal.ReadInt32(data) is 2 or 3;} + finally {Marshal.FreeHGlobal(data);} + } + internal static Mutex WorkspaceMutex(string name) + { + using var identity=WindowsIdentity.GetCurrent(); + if(!ConvertStringSecurityDescriptorToSecurityDescriptor($"D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;{identity.User!.Value})S:(ML;;NW;;;ME)",1,out var descriptor,out _)) throw new Win32Exception(); + try + { + var security=new SecurityAttributes {Length=Marshal.SizeOf(),Descriptor=descriptor}; + var handle=CreateMutexEx(ref security,name,0,0x100001); // SYNCHRONIZE | MUTEX_MODIFY_STATE + if(handle.IsInvalid) throw new Win32Exception(); + return new Mutex {SafeWaitHandle=handle}; + } + finally {LocalFree(descriptor);} + } + [StructLayout(LayoutKind.Sequential)] private struct SecurityAttributes {public int Length;public IntPtr Descriptor;public int Inherit;} + [DllImport("advapi32.dll")] private static extern int RegOpenCurrentUser(int access,out SafeRegistryHandle key); + [DllImport("advapi32.dll",SetLastError=true)] private static extern bool GetTokenInformation(IntPtr token,int kind,IntPtr info,int length,out int required); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor(string text,uint revision,out IntPtr descriptor,out uint size); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,EntryPoint="CreateMutexExW")] private static extern SafeWaitHandle CreateMutexEx(ref SecurityAttributes attributes,string name,uint flags,uint access); + [DllImport("kernel32.dll")] private static extern IntPtr LocalFree(IntPtr memory); +} diff --git a/src/Pathman.Gui/MainWindow.xaml.cs b/src/Pathman.Gui/MainWindow.xaml.cs index 6f9e484..80c3605 100644 --- a/src/Pathman.Gui/MainWindow.xaml.cs +++ b/src/Pathman.Gui/MainWindow.xaml.cs @@ -220,7 +220,7 @@ private async void CheckWorkspace(object? sender,EventArgs e) } private void ShowMonitorNotification(EnvironmentWorkspace state) { - var pending=state.MonitoringEnabled && state.AutoArrangeMachinePath && state.PendingPathOrganizations.Any(item=>item.Scope==Pathman.Core.Models.VariableScope.Machine); + 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}"; 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)); @@ -230,6 +230,7 @@ private void ShowMonitorNotification(EnvironmentWorkspace state) } private async Task ApplyPendingMonitoringAsync(bool automatic=false) { + if(new GlobalMonitoring(layout).IsEnabled) return; if(monitorApplying) return; monitorApplying=true;monitor.SetApplying(true); try diff --git a/src/Pathman.Gui/Views/MonitorView.xaml b/src/Pathman.Gui/Views/MonitorView.xaml index f8b0678..ddab8f2 100644 --- a/src/Pathman.Gui/Views/MonitorView.xaml +++ b/src/Pathman.Gui/Views/MonitorView.xaml @@ -64,12 +64,25 @@ + + + + + + + + + + + + + +