mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-30 16:07:29 +01:00
Compare commits
11 Commits
shawn/fixA
...
leilzh/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f23bd3ec2e | ||
|
|
f2d25d8eef | ||
|
|
6d4f56cd83 | ||
|
|
4986915dae | ||
|
|
cc2dce8816 | ||
|
|
0de2af77ac | ||
|
|
4694e99477 | ||
|
|
64cabc8789 | ||
|
|
989e005500 | ||
|
|
5f124cec55 | ||
|
|
8ec530c65e |
@@ -565,7 +565,7 @@ perl(?:\s+-[a-zA-Z]\w*)+
|
||||
regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\)
|
||||
|
||||
# regex choice
|
||||
\(\?:[^)]+\|[^)]+\)
|
||||
# \(\?:[^)]+\|[^)]+\)
|
||||
|
||||
# proto
|
||||
^\s*(\w+)\s\g{-1} =
|
||||
|
||||
4
.github/actions/spell-check/excludes.txt
vendored
4
.github/actions/spell-check/excludes.txt
vendored
@@ -104,8 +104,12 @@
|
||||
^src/common/ManagedCommon/ColorFormatHelper\.cs$
|
||||
^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$
|
||||
^src/common/sysinternals/Eula/
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
|
||||
^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$
|
||||
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
|
||||
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/.*\.TestData\.cs$
|
||||
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
|
||||
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
|
||||
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$
|
||||
|
||||
6
.github/actions/spell-check/expect.txt
vendored
6
.github/actions/spell-check/expect.txt
vendored
@@ -597,6 +597,7 @@ frm
|
||||
FROMTOUCH
|
||||
fsanitize
|
||||
fsmgmt
|
||||
ftps
|
||||
fuzzingtesting
|
||||
fxf
|
||||
FZE
|
||||
@@ -1329,7 +1330,7 @@ phwnd
|
||||
pici
|
||||
pidl
|
||||
PIDLIST
|
||||
PII
|
||||
pii
|
||||
pinfo
|
||||
pinvoke
|
||||
pipename
|
||||
@@ -1531,6 +1532,7 @@ riid
|
||||
RKey
|
||||
RNumber
|
||||
rollups
|
||||
ROOTOWNER
|
||||
rop
|
||||
ROUNDSMALL
|
||||
ROWSETEXT
|
||||
@@ -1715,6 +1717,7 @@ srw
|
||||
srwlock
|
||||
sse
|
||||
ssf
|
||||
Ssn
|
||||
sszzz
|
||||
STACKFRAME
|
||||
stackoverflow
|
||||
@@ -1824,6 +1827,7 @@ TEXTBOXNEWLINE
|
||||
textextractor
|
||||
TEXTINCLUDE
|
||||
tfopen
|
||||
tgamma
|
||||
tgz
|
||||
THEMECHANGED
|
||||
themeresources
|
||||
|
||||
@@ -300,6 +300,10 @@
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/CommandPalette/Tests/">
|
||||
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
@@ -356,6 +360,10 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj" Id="2eca18b7-33b7-4829-88f1-439b20fd60f6">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/CommandPalette/UI/">
|
||||
<Project Path="src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj">
|
||||
|
||||
@@ -610,6 +610,13 @@ UINT __stdcall InstallPackageIdentityMSIXCA(MSIHANDLE hInstall)
|
||||
hr = WcaInitialize(hInstall, "InstallPackageIdentityMSIXCA");
|
||||
ExitOnFailure(hr, "Failed to initialize");
|
||||
|
||||
// Double-check: Only install on Windows 11 or greater
|
||||
if (!package::IsWin11OrGreater())
|
||||
{
|
||||
Logger::info(L"Skipping PackageIdentity MSIX installation - not Windows 11 or greater");
|
||||
goto LExit;
|
||||
}
|
||||
|
||||
hr = WcaGetProperty(L"CustomActionData", &customActionData);
|
||||
ExitOnFailure(hr, "Failed to get CustomActionData property");
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<Custom Action="SetBundleInstallLocation" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
|
||||
<Custom Action="ApplyModulesRegistryChangeSets" After="InstallFiles" Condition="NOT Installed" />
|
||||
<Custom Action="InstallCmdPalPackage" After="InstallFiles" Condition="NOT Installed" />
|
||||
<Custom Action="InstallPackageIdentityMSIX" After="InstallFiles" Condition="NOT Installed AND WINDOWSBUILDNUMBER >= 22000" />
|
||||
<Custom Action="InstallPackageIdentityMSIX" After="InstallFiles" Condition="(NOT Installed) AND (WINDOWSBUILDNUMBER >= 22000)" />
|
||||
<Custom Action="override Wix4CloseApplications_$(sys.BUILDARCHSHORT)" Before="RemoveFiles" />
|
||||
<Custom Action="RemovePowerToysSchTasks" After="RemoveFiles" />
|
||||
<!-- TODO: Use to activate embedded MSIX -->
|
||||
|
||||
@@ -3,9 +3,27 @@
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace ExprtkCalculator::internal
|
||||
{
|
||||
static double factorial(const double n)
|
||||
{
|
||||
// Only allow non-negative integers
|
||||
if (n < 0.0 || std::floor(n) != n)
|
||||
{
|
||||
return std::numeric_limits<double>::quiet_NaN();
|
||||
}
|
||||
return std::tgamma(n + 1.0);
|
||||
}
|
||||
|
||||
static double sign(const double n)
|
||||
{
|
||||
if (n > 0.0) return 1.0;
|
||||
if (n < 0.0) return -1.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
std::wstring ToWStringFullPrecision(double value)
|
||||
{
|
||||
@@ -25,6 +43,9 @@ namespace ExprtkCalculator::internal
|
||||
symbol_table.add_constant(name, value);
|
||||
}
|
||||
|
||||
symbol_table.add_function("factorial", factorial);
|
||||
symbol_table.add_function("sign", sign);
|
||||
|
||||
exprtk::expression<double> expression;
|
||||
expression.register_symbol_table(symbol_table);
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ namespace CommonSharedConstants
|
||||
|
||||
const wchar_t ALWAYS_ON_TOP_TERMINATE_EVENT[] = L"Local\\AlwaysOnTopTerminateEvent-cfdf1eae-791f-4953-8021-2f18f3837eae";
|
||||
|
||||
const wchar_t ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT[] = L"Local\\AlwaysOnTopIncreaseOpacityEvent-a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
const wchar_t ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT[] = L"Local\\AlwaysOnTopDecreaseOpacityEvent-b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
// Path to the event used by PowerAccent
|
||||
const wchar_t POWERACCENT_EXIT_EVENT[] = L"Local\\PowerToysPowerAccentExitEvent-53e93389-d19a-4fbb-9b36-1981c8965e17";
|
||||
|
||||
|
||||
@@ -153,9 +153,21 @@ LRESULT AlwaysOnTop::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lp
|
||||
{
|
||||
if (message == WM_HOTKEY)
|
||||
{
|
||||
int hotkeyId = static_cast<int>(wparam);
|
||||
if (HWND fw{ GetForegroundWindow() })
|
||||
{
|
||||
ProcessCommand(fw);
|
||||
if (hotkeyId == static_cast<int>(HotkeyId::Pin))
|
||||
{
|
||||
ProcessCommand(fw);
|
||||
}
|
||||
else if (hotkeyId == static_cast<int>(HotkeyId::IncreaseOpacity))
|
||||
{
|
||||
StepWindowTransparency(fw, Settings::transparencyStep);
|
||||
}
|
||||
else if (hotkeyId == static_cast<int>(HotkeyId::DecreaseOpacity))
|
||||
{
|
||||
StepWindowTransparency(fw, -Settings::transparencyStep);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (message == WM_PRIV_SETTINGS_CHANGED)
|
||||
@@ -191,6 +203,10 @@ void AlwaysOnTop::ProcessCommand(HWND window)
|
||||
m_topmostWindows.erase(iter);
|
||||
}
|
||||
|
||||
// Restore transparency when unpinning
|
||||
RestoreWindowAlpha(window);
|
||||
m_windowOriginalLayeredState.erase(window);
|
||||
|
||||
Trace::AlwaysOnTop::UnpinWindow();
|
||||
}
|
||||
}
|
||||
@@ -200,6 +216,7 @@ void AlwaysOnTop::ProcessCommand(HWND window)
|
||||
{
|
||||
soundType = Sound::Type::On;
|
||||
AssignBorder(window);
|
||||
|
||||
Trace::AlwaysOnTop::PinWindow();
|
||||
}
|
||||
}
|
||||
@@ -269,11 +286,22 @@ void AlwaysOnTop::RegisterHotkey() const
|
||||
{
|
||||
if (m_useCentralizedLLKH)
|
||||
{
|
||||
// All hotkeys are handled by centralized LLKH
|
||||
return;
|
||||
}
|
||||
|
||||
// Register hotkeys only when not using centralized LLKH
|
||||
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::Pin));
|
||||
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity));
|
||||
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity));
|
||||
|
||||
// Register pin hotkey
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::Pin), AlwaysOnTopSettings::settings().hotkey.get_modifiers(), AlwaysOnTopSettings::settings().hotkey.get_code());
|
||||
|
||||
// Register transparency hotkeys using the same modifiers as the pin hotkey
|
||||
UINT modifiers = AlwaysOnTopSettings::settings().hotkey.get_modifiers();
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity), modifiers, VK_OEM_PLUS);
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity), modifiers, VK_OEM_MINUS);
|
||||
}
|
||||
|
||||
void AlwaysOnTop::RegisterLLKH()
|
||||
@@ -285,6 +313,8 @@ void AlwaysOnTop::RegisterLLKH()
|
||||
|
||||
m_hPinEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT);
|
||||
m_hTerminateEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_TERMINATE_EVENT);
|
||||
m_hIncreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT);
|
||||
m_hDecreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT);
|
||||
|
||||
if (!m_hPinEvent)
|
||||
{
|
||||
@@ -298,30 +328,54 @@ void AlwaysOnTop::RegisterLLKH()
|
||||
return;
|
||||
}
|
||||
|
||||
HANDLE handles[2] = { m_hPinEvent,
|
||||
m_hTerminateEvent };
|
||||
if (!m_hIncreaseOpacityEvent)
|
||||
{
|
||||
Logger::warn(L"Failed to create increaseOpacityEvent. {}", get_last_error_or_default(GetLastError()));
|
||||
}
|
||||
|
||||
if (!m_hDecreaseOpacityEvent)
|
||||
{
|
||||
Logger::warn(L"Failed to create decreaseOpacityEvent. {}", get_last_error_or_default(GetLastError()));
|
||||
}
|
||||
|
||||
HANDLE handles[4] = { m_hPinEvent,
|
||||
m_hTerminateEvent,
|
||||
m_hIncreaseOpacityEvent,
|
||||
m_hDecreaseOpacityEvent };
|
||||
|
||||
m_thread = std::thread([this, handles]() {
|
||||
MSG msg;
|
||||
while (m_running)
|
||||
{
|
||||
DWORD dwEvt = MsgWaitForMultipleObjects(2, handles, false, INFINITE, QS_ALLINPUT);
|
||||
DWORD dwEvt = MsgWaitForMultipleObjects(4, handles, false, INFINITE, QS_ALLINPUT);
|
||||
if (!m_running)
|
||||
{
|
||||
break;
|
||||
}
|
||||
switch (dwEvt)
|
||||
{
|
||||
case WAIT_OBJECT_0:
|
||||
case WAIT_OBJECT_0: // Pin event
|
||||
if (HWND fw{ GetForegroundWindow() })
|
||||
{
|
||||
ProcessCommand(fw);
|
||||
}
|
||||
break;
|
||||
case WAIT_OBJECT_0 + 1:
|
||||
case WAIT_OBJECT_0 + 1: // Terminate event
|
||||
PostThreadMessage(m_mainThreadId, WM_QUIT, 0, 0);
|
||||
break;
|
||||
case WAIT_OBJECT_0 + 2:
|
||||
case WAIT_OBJECT_0 + 2: // Increase opacity event
|
||||
if (HWND fw{ GetForegroundWindow() })
|
||||
{
|
||||
StepWindowTransparency(fw, Settings::transparencyStep);
|
||||
}
|
||||
break;
|
||||
case WAIT_OBJECT_0 + 3: // Decrease opacity event
|
||||
if (HWND fw{ GetForegroundWindow() })
|
||||
{
|
||||
StepWindowTransparency(fw, -Settings::transparencyStep);
|
||||
}
|
||||
break;
|
||||
case WAIT_OBJECT_0 + 4: // Message queue
|
||||
if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
|
||||
{
|
||||
TranslateMessage(&msg);
|
||||
@@ -370,9 +424,12 @@ void AlwaysOnTop::UnpinAll()
|
||||
{
|
||||
Logger::error(L"Unpinning topmost window failed");
|
||||
}
|
||||
// Restore transparency when unpinning all
|
||||
RestoreWindowAlpha(topWindow);
|
||||
}
|
||||
|
||||
m_topmostWindows.clear();
|
||||
m_windowOriginalLayeredState.clear();
|
||||
}
|
||||
|
||||
void AlwaysOnTop::CleanUp()
|
||||
@@ -456,6 +513,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
|
||||
for (const auto window : toErase)
|
||||
{
|
||||
m_topmostWindows.erase(window);
|
||||
m_windowOriginalLayeredState.erase(window);
|
||||
}
|
||||
|
||||
switch (data->event)
|
||||
@@ -556,4 +614,166 @@ void AlwaysOnTop::RefreshBorders()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HWND AlwaysOnTop::ResolveTransparencyTargetWindow(HWND window)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Only allow transparency changes on pinned windows
|
||||
if (!IsPinned(window))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
|
||||
void AlwaysOnTop::StepWindowTransparency(HWND window, int delta)
|
||||
{
|
||||
HWND targetWindow = ResolveTransparencyTargetWindow(window);
|
||||
if (!targetWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int currentTransparency = Settings::maxTransparencyPercentage;
|
||||
LONG exStyle = GetWindowLong(targetWindow, GWL_EXSTYLE);
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
BYTE alpha = 255;
|
||||
if (GetLayeredWindowAttributes(targetWindow, nullptr, &alpha, nullptr))
|
||||
{
|
||||
currentTransparency = (alpha * 100) / 255;
|
||||
}
|
||||
}
|
||||
|
||||
int newTransparency = (std::max)(Settings::minTransparencyPercentage,
|
||||
(std::min)(Settings::maxTransparencyPercentage, currentTransparency + delta));
|
||||
|
||||
if (newTransparency != currentTransparency)
|
||||
{
|
||||
ApplyWindowAlpha(targetWindow, newTransparency);
|
||||
|
||||
if (AlwaysOnTopSettings::settings().enableSound)
|
||||
{
|
||||
m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity);
|
||||
}
|
||||
|
||||
Logger::debug(L"Transparency adjusted to {}%", newTransparency);
|
||||
}
|
||||
}
|
||||
|
||||
void AlwaysOnTop::ApplyWindowAlpha(HWND window, int percentage)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
percentage = (std::max)(Settings::minTransparencyPercentage,
|
||||
(std::min)(Settings::maxTransparencyPercentage, percentage));
|
||||
|
||||
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
bool isCurrentlyLayered = (exStyle & WS_EX_LAYERED) != 0;
|
||||
|
||||
// Cache original state on first transparency application
|
||||
if (m_windowOriginalLayeredState.find(window) == m_windowOriginalLayeredState.end())
|
||||
{
|
||||
WindowLayeredState state;
|
||||
state.hadLayeredStyle = isCurrentlyLayered;
|
||||
|
||||
if (isCurrentlyLayered)
|
||||
{
|
||||
BYTE alpha = 255;
|
||||
COLORREF colorKey = 0;
|
||||
DWORD flags = 0;
|
||||
if (GetLayeredWindowAttributes(window, &colorKey, &alpha, &flags))
|
||||
{
|
||||
state.originalAlpha = alpha;
|
||||
state.usedColorKey = (flags & LWA_COLORKEY) != 0;
|
||||
state.colorKey = colorKey;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::warn(L"GetLayeredWindowAttributes failed for layered window, skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
m_windowOriginalLayeredState[window] = state;
|
||||
}
|
||||
|
||||
// Clear WS_EX_LAYERED first to ensure SetLayeredWindowAttributes works
|
||||
if (isCurrentlyLayered)
|
||||
{
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
}
|
||||
|
||||
BYTE alphaValue = static_cast<BYTE>((255 * percentage) / 100);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
|
||||
SetLayeredWindowAttributes(window, 0, alphaValue, LWA_ALPHA);
|
||||
}
|
||||
|
||||
void AlwaysOnTop::RestoreWindowAlpha(HWND window)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
auto it = m_windowOriginalLayeredState.find(window);
|
||||
|
||||
if (it != m_windowOriginalLayeredState.end())
|
||||
{
|
||||
const auto& originalState = it->second;
|
||||
|
||||
if (originalState.hadLayeredStyle)
|
||||
{
|
||||
// Window originally had WS_EX_LAYERED - restore original attributes
|
||||
// Clear and re-add to ensure clean state
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
}
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
|
||||
|
||||
// Restore original alpha and/or color key
|
||||
DWORD flags = LWA_ALPHA;
|
||||
if (originalState.usedColorKey)
|
||||
{
|
||||
flags |= LWA_COLORKEY;
|
||||
}
|
||||
SetLayeredWindowAttributes(window, originalState.colorKey, originalState.originalAlpha, flags);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Window originally didn't have WS_EX_LAYERED - remove it completely
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
}
|
||||
}
|
||||
|
||||
m_windowOriginalLayeredState.erase(it);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: no cached state, just remove layered style
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <common/hooks/WinHookEvent.h>
|
||||
#include <common/notifications/NotificationUtil.h>
|
||||
#include <common/utils/window.h>
|
||||
|
||||
class AlwaysOnTop : public SettingsObserver
|
||||
{
|
||||
@@ -38,6 +39,8 @@ private:
|
||||
enum class HotkeyId : int
|
||||
{
|
||||
Pin = 1,
|
||||
IncreaseOpacity = 2,
|
||||
DecreaseOpacity = 3,
|
||||
};
|
||||
|
||||
static inline AlwaysOnTop* s_instance = nullptr;
|
||||
@@ -48,8 +51,20 @@ private:
|
||||
HWND m_window{ nullptr };
|
||||
HINSTANCE m_hinstance;
|
||||
std::map<HWND, std::unique_ptr<WindowBorder>> m_topmostWindows{};
|
||||
|
||||
// Store original window layered state for proper restoration
|
||||
struct WindowLayeredState {
|
||||
bool hadLayeredStyle = false;
|
||||
BYTE originalAlpha = 255;
|
||||
bool usedColorKey = false;
|
||||
COLORREF colorKey = 0;
|
||||
};
|
||||
std::map<HWND, WindowLayeredState> m_windowOriginalLayeredState{};
|
||||
|
||||
HANDLE m_hPinEvent;
|
||||
HANDLE m_hTerminateEvent;
|
||||
HANDLE m_hIncreaseOpacityEvent;
|
||||
HANDLE m_hDecreaseOpacityEvent;
|
||||
DWORD m_mainThreadId;
|
||||
std::thread m_thread;
|
||||
const bool m_useCentralizedLLKH;
|
||||
@@ -78,6 +93,12 @@ private:
|
||||
bool AssignBorder(HWND window);
|
||||
void RefreshBorders();
|
||||
|
||||
// Transparency methods
|
||||
HWND ResolveTransparencyTargetWindow(HWND window);
|
||||
void StepWindowTransparency(HWND window, int delta);
|
||||
void ApplyWindowAlpha(HWND window, int percentage);
|
||||
void RestoreWindowAlpha(HWND window);
|
||||
|
||||
virtual void SettingsUpdate(SettingId type) override;
|
||||
|
||||
static void CALLBACK WinHookProc(HWINEVENTHOOK winEventHook,
|
||||
|
||||
@@ -15,6 +15,9 @@ class SettingsObserver;
|
||||
struct Settings
|
||||
{
|
||||
PowerToysSettings::HotkeyObject hotkey = PowerToysSettings::HotkeyObject::from_settings(true, true, false, false, 84); // win + ctrl + T
|
||||
static constexpr int minTransparencyPercentage = 20; // minimum transparency (can't go below 20%)
|
||||
static constexpr int maxTransparencyPercentage = 100; // maximum (fully opaque)
|
||||
static constexpr int transparencyStep = 10; // step size for +/- adjustment
|
||||
bool enableFrame = true;
|
||||
bool enableSound = true;
|
||||
bool roundCornersEnabled = true;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include "pch.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <mmsystem.h> // sound
|
||||
|
||||
class Sound
|
||||
@@ -12,12 +11,10 @@ public:
|
||||
{
|
||||
On,
|
||||
Off,
|
||||
IncreaseOpacity,
|
||||
DecreaseOpacity,
|
||||
};
|
||||
|
||||
Sound()
|
||||
: isPlaying(false)
|
||||
{}
|
||||
|
||||
void Play(Type type)
|
||||
{
|
||||
BOOL success = false;
|
||||
@@ -29,6 +26,12 @@ public:
|
||||
case Type::Off:
|
||||
success = PlaySound(TEXT("Media\\Speech Sleep.wav"), NULL, SND_FILENAME | SND_ASYNC);
|
||||
break;
|
||||
case Type::IncreaseOpacity:
|
||||
success = PlaySound(TEXT("Media\\Windows Hardware Insert.wav"), NULL, SND_FILENAME | SND_ASYNC);
|
||||
break;
|
||||
case Type::DecreaseOpacity:
|
||||
success = PlaySound(TEXT("Media\\Windows Hardware Remove.wav"), NULL, SND_FILENAME | SND_ASYNC);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -38,7 +41,4 @@ public:
|
||||
Logger::error(L"Sound playing error");
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<bool> isPlaying;
|
||||
};
|
||||
@@ -105,17 +105,28 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
virtual bool on_hotkey(size_t /*hotkeyId*/) override
|
||||
virtual bool on_hotkey(size_t hotkeyId) override
|
||||
{
|
||||
if (m_enabled)
|
||||
{
|
||||
Logger::trace(L"AlwaysOnTop hotkey pressed");
|
||||
Logger::trace(L"AlwaysOnTop hotkey pressed, id={}", hotkeyId);
|
||||
if (!is_process_running())
|
||||
{
|
||||
Enable();
|
||||
}
|
||||
|
||||
SetEvent(m_hPinEvent);
|
||||
if (hotkeyId == 0)
|
||||
{
|
||||
SetEvent(m_hPinEvent);
|
||||
}
|
||||
else if (hotkeyId == 1)
|
||||
{
|
||||
SetEvent(m_hIncreaseOpacityEvent);
|
||||
}
|
||||
else if (hotkeyId == 2)
|
||||
{
|
||||
SetEvent(m_hDecreaseOpacityEvent);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -125,19 +136,48 @@ public:
|
||||
|
||||
virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
|
||||
{
|
||||
size_t count = 0;
|
||||
|
||||
// Hotkey 0: Pin/Unpin (e.g., Win+Ctrl+T)
|
||||
if (m_hotkey.key)
|
||||
{
|
||||
if (hotkeys && buffer_size >= 1)
|
||||
if (hotkeys && buffer_size > count)
|
||||
{
|
||||
hotkeys[0] = m_hotkey;
|
||||
hotkeys[count] = m_hotkey;
|
||||
Logger::trace(L"AlwaysOnTop hotkey[0]: win={}, ctrl={}, shift={}, alt={}, key={}",
|
||||
m_hotkey.win, m_hotkey.ctrl, m_hotkey.shift, m_hotkey.alt, m_hotkey.key);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
else
|
||||
// Hotkey 1: Increase opacity (same modifiers + VK_OEM_PLUS '=')
|
||||
if (m_hotkey.key)
|
||||
{
|
||||
return 0;
|
||||
if (hotkeys && buffer_size > count)
|
||||
{
|
||||
hotkeys[count] = m_hotkey;
|
||||
hotkeys[count].key = VK_OEM_PLUS; // '=' key
|
||||
Logger::trace(L"AlwaysOnTop hotkey[1] (increase opacity): win={}, ctrl={}, shift={}, alt={}, key={}",
|
||||
hotkeys[count].win, hotkeys[count].ctrl, hotkeys[count].shift, hotkeys[count].alt, hotkeys[count].key);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
// Hotkey 2: Decrease opacity (same modifiers + VK_OEM_MINUS '-')
|
||||
if (m_hotkey.key)
|
||||
{
|
||||
if (hotkeys && buffer_size > count)
|
||||
{
|
||||
hotkeys[count] = m_hotkey;
|
||||
hotkeys[count].key = VK_OEM_MINUS; // '-' key
|
||||
Logger::trace(L"AlwaysOnTop hotkey[2] (decrease opacity): win={}, ctrl={}, shift={}, alt={}, key={}",
|
||||
hotkeys[count].win, hotkeys[count].ctrl, hotkeys[count].shift, hotkeys[count].alt, hotkeys[count].key);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
Logger::trace(L"AlwaysOnTop get_hotkeys returning count={}", count);
|
||||
return count;
|
||||
}
|
||||
|
||||
// Enable the powertoy
|
||||
@@ -175,6 +215,8 @@ public:
|
||||
app_key = NonLocalizable::ModuleKey;
|
||||
m_hPinEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT);
|
||||
m_hTerminateEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_TERMINATE_EVENT);
|
||||
m_hIncreaseOpacityEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT);
|
||||
m_hDecreaseOpacityEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT);
|
||||
init_settings();
|
||||
}
|
||||
|
||||
@@ -292,6 +334,8 @@ private:
|
||||
// Handle to event used to pin/unpin windows
|
||||
HANDLE m_hPinEvent;
|
||||
HANDLE m_hTerminateEvent;
|
||||
HANDLE m_hIncreaseOpacityEvent;
|
||||
HANDLE m_hDecreaseOpacityEvent;
|
||||
};
|
||||
|
||||
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI.ViewModels\\Microsoft.CmdPal.UI.ViewModels.csproj",
|
||||
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI\\Microsoft.CmdPal.UI.csproj",
|
||||
"src\\modules\\cmdpal\\Microsoft.Terminal.UI\\Microsoft.Terminal.UI.vcxproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Core.Common.UnitTests\\Microsoft.CmdPal.Core.Common.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Apps.UnitTests\\Microsoft.CmdPal.Ext.Apps.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj",
|
||||
@@ -29,6 +30,7 @@
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.WindowWalker.UnitTests\\Microsoft.CmdPal.Ext.WindowWalker.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UI.ViewModels.UnitTests\\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UITests\\Microsoft.CmdPal.UITests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Apps\\Microsoft.CmdPal.Ext.Apps.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Bookmark\\Microsoft.CmdPal.Ext.Bookmarks.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Calc\\Microsoft.CmdPal.Ext.Calc.csproj",
|
||||
|
||||
@@ -9,4 +9,18 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Properties\Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
76
src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs
generated
Normal file
76
src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs
generated
Normal file
@@ -0,0 +1,76 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Properties {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Core.Common.Properties.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This is an error report generated by Windows Command Palette.
|
||||
///If you are seeing this, it means something went a little sideways in the app.
|
||||
///You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
|
||||
///
|
||||
///(While you’re at it, give the details below a quick skim — just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.).
|
||||
/// </summary>
|
||||
internal static string ErrorReport_Global_Preamble {
|
||||
get {
|
||||
return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ErrorReport_Global_Preamble" xml:space="preserve">
|
||||
<value>This is an error report generated by Windows Command Palette.
|
||||
If you are seeing this, it means something went a little sideways in the app.
|
||||
You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
|
||||
|
||||
(While you’re at it, give the details below a quick skim — just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.)</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Windows.ApplicationModel;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Reports;
|
||||
|
||||
public sealed class ErrorReportBuilder : IErrorReportBuilder
|
||||
{
|
||||
private readonly ErrorReportSanitizer _sanitizer = new();
|
||||
|
||||
private static string Preamble => Properties.Resources.ErrorReport_Global_Preamble;
|
||||
|
||||
public string BuildReport(Exception exception, string context, bool redactPii = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
|
||||
var exceptionMessage = CoalesceExceptionMessage(exception);
|
||||
var sanitizedMessage = redactPii ? _sanitizer.Sanitize(exceptionMessage) : exceptionMessage;
|
||||
var sanitizedFormattedException = redactPii ? _sanitizer.Sanitize(exception.ToString()) : exception.ToString();
|
||||
|
||||
// Note:
|
||||
// - do not localize technical part of the report, we need to ensure it can be read by developers
|
||||
// - keep timestamp format should be consistent with the log (makes it easier to search)
|
||||
var technicalContent =
|
||||
$"""
|
||||
============================================================
|
||||
Summary:
|
||||
Message: {sanitizedMessage}
|
||||
Type: {exception.GetType().FullName}
|
||||
Source: {exception.Source ?? "N/A"}
|
||||
Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fffffff}
|
||||
HRESULT: 0x{exception.HResult:X8} ({exception.HResult})
|
||||
Context: {context ?? "N/A"}
|
||||
|
||||
Application:
|
||||
App version: {GetAppVersionSafe()}
|
||||
Is elevated: {GetElevationStatus()}
|
||||
|
||||
Environment:
|
||||
OS version: {RuntimeInformation.OSDescription}
|
||||
OS architecture: {RuntimeInformation.OSArchitecture}
|
||||
Runtime identifier: {RuntimeInformation.RuntimeIdentifier}
|
||||
Framework: {RuntimeInformation.FrameworkDescription}
|
||||
Process architecture: {RuntimeInformation.ProcessArchitecture}
|
||||
Culture: {CultureInfo.CurrentCulture.Name}
|
||||
UI culture: {CultureInfo.CurrentUICulture.Name}
|
||||
|
||||
Stack Trace:
|
||||
{exception.StackTrace}
|
||||
|
||||
------------------ Full Exception Details ------------------
|
||||
{sanitizedFormattedException}
|
||||
|
||||
============================================================
|
||||
""";
|
||||
|
||||
return $"""
|
||||
{Preamble}
|
||||
{technicalContent}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string GetElevationStatus()
|
||||
{
|
||||
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
|
||||
try
|
||||
{
|
||||
var isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
return isElevated ? "yes" : "no";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "Failed to determine elevation status";
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetAppVersionSafe()
|
||||
{
|
||||
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
|
||||
try
|
||||
{
|
||||
var version = Package.Current.Id.Version;
|
||||
return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "Failed to retrieve app version";
|
||||
}
|
||||
}
|
||||
|
||||
private static string CoalesceExceptionMessage(Exception exception)
|
||||
{
|
||||
// let's try to get a message from the exception or inferred it from the HRESULT
|
||||
// to show at least something
|
||||
var message = exception.Message;
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
var temp = Marshal.GetExceptionForHR(exception.HResult)?.Message;
|
||||
if (!string.IsNullOrWhiteSpace(temp))
|
||||
{
|
||||
message = temp + $" (inferred from HRESULT 0x{exception.HResult:X8})";
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
message = "No message available";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for creating human-readable error reports from exceptions,
|
||||
/// suitable for logs, telemetry, or user-facing diagnostics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations should ensure reports are consistent and optionally redact
|
||||
/// personally identifiable or sensitive information when requested.
|
||||
/// </remarks>
|
||||
public interface IErrorReportBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a formatted error report for the specified <paramref name="exception"/> and <paramref name="context"/>.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception that triggered the error report.</param>
|
||||
/// <param name="context">
|
||||
/// A short, human-readable description of where or what was being executed when the error occurred
|
||||
/// (e.g., the operation name, component, or scenario).
|
||||
/// </param>
|
||||
/// <param name="redactPii">
|
||||
/// When true, attempts to remove or obfuscate personally identifiable or sensitive information
|
||||
/// (such as file paths, emails, machine/usernames, tokens). Defaults to true.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// A formatted string containing the error report, suitable for logging or telemetry submission.
|
||||
/// </returns>
|
||||
string BuildReport(Exception exception, string context, bool redactPii = true);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a service that sanitizes text by applying a set of configurable, regex-based rules.
|
||||
/// Typical use cases include masking secrets, removing PII, or normalizing logs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// - Rules are applied in their registered order; rule ordering may affect the final output.
|
||||
/// - Each rule should have a unique <c>description</c> that acts as its identifier.
|
||||
/// </remarks>
|
||||
/// <seealso cref="SanitizationRule"/>
|
||||
public interface ITextSanitizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Sanitizes the specified input by applying all registered rules in order.
|
||||
/// </summary>
|
||||
/// <param name="input">The input text to sanitize. Implementations should handle <see langword="null"/> safely.</param>
|
||||
/// <returns>The sanitized text after all rules are applied.</returns>
|
||||
string Sanitize(string? input);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a sanitization rule using a .NET regular expression pattern and a replacement string.
|
||||
/// </summary>
|
||||
/// <param name="pattern">A .NET regular expression pattern used to match text to sanitize.</param>
|
||||
/// <param name="replacement">
|
||||
/// The replacement text used by <c>Regex.Replace</c>. Supports standard regex replacement tokens,
|
||||
/// including numbered groups (<c>$1</c>) and named groups (<c>${name}</c>).
|
||||
/// </param>
|
||||
/// <param name="description">
|
||||
/// A human-readable, unique identifier for the rule. Used to list, test, and remove the rule.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Implementations typically validate <paramref name="pattern"/> is a valid regex and may reject duplicate <paramref name="description"/> values.
|
||||
/// </remarks>
|
||||
void AddRule(string pattern, string replacement, string description = "");
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously added rule identified by its <paramref name="description"/>.
|
||||
/// </summary>
|
||||
/// <param name="description">The unique description of the rule to remove.</param>
|
||||
void RemoveRule(string description);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a read-only snapshot of the currently registered sanitization rules in application order.
|
||||
/// </summary>
|
||||
/// <returns>A read-only list of <see cref="SanitizationRule"/> items.</returns>
|
||||
IReadOnlyList<SanitizationRule> GetRules();
|
||||
|
||||
/// <summary>
|
||||
/// Tests a single rule, identified by <paramref name="ruleDescription"/>, against the provided <paramref name="input"/>,
|
||||
/// without applying other rules.
|
||||
/// </summary>
|
||||
/// <param name="input">The input text to test.</param>
|
||||
/// <param name="ruleDescription">The description (identifier) of the rule to test.</param>
|
||||
/// <returns>The result of applying only the specified rule to the input.</returns>
|
||||
string TestRule(string input, string ruleDescription);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
public readonly record struct SanitizationRule
|
||||
{
|
||||
public SanitizationRule(Regex regex, string replacement, string description = "")
|
||||
{
|
||||
Regex = regex;
|
||||
Replacement = replacement;
|
||||
Evaluator = null;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public SanitizationRule(Regex regex, MatchEvaluator evaluator, string description = "")
|
||||
{
|
||||
Regex = regex;
|
||||
Evaluator = evaluator;
|
||||
Replacement = null;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public Regex Regex { get; }
|
||||
|
||||
public string? Replacement { get; }
|
||||
|
||||
public MatchEvaluator? Evaluator { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public override string ToString() => $"{Description}: {Regex} -> {Replacement ?? "<evaluator>"}";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class ConnectionStringRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
[GeneratedRegex(@"(Server|Data Source|Initial Catalog|Database|User ID|Username|Password|Pwd|Uid)\s*=\s*(?:""[^""]*""|'[^']*'|[^;,\s]+)",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex ConnectionParamRx();
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(ConnectionParamRx(), "$1=[REDACTED]", "Connection string parameters");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class EnvironmentPropertiesRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
List<SanitizationRule> rules = [];
|
||||
|
||||
var machine = Environment.MachineName;
|
||||
if (!string.IsNullOrWhiteSpace(machine))
|
||||
{
|
||||
var rx = new Regex(@"\b" + Regex.Escape(machine) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
|
||||
rules.Add(new(rx, "[MACHINE_NAME_REDACTED]", "Machine name"));
|
||||
}
|
||||
|
||||
var domain = Environment.UserDomainName;
|
||||
if (!string.IsNullOrWhiteSpace(domain))
|
||||
{
|
||||
var rx = new Regex(@"\b" + Regex.Escape(domain) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
|
||||
rules.Add(new(rx, "[USER_DOMAIN_NAME_REDACTED]", "User domain name"));
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
/// <summary>
|
||||
/// Specific sanitizer used for error report content. Builds on top of the generic TextSanitizer.
|
||||
/// </summary>
|
||||
public sealed class ErrorReportSanitizer
|
||||
{
|
||||
private readonly TextSanitizer _sanitizer = new(BuildProviders(), onGuardrailTriggered: OnGuardrailTriggered);
|
||||
|
||||
private static void OnGuardrailTriggered(GuardrailEventArgs eventArgs)
|
||||
{
|
||||
var msg = $"Sanitization guardrail triggered for rule '{eventArgs.RuleDescription}': original length={eventArgs.OriginalLength}, result length={eventArgs.ResultLength}, ratio={eventArgs.Ratio:F2}, threshold={eventArgs.Threshold:F2}";
|
||||
CoreLogger.LogDebug(msg);
|
||||
}
|
||||
|
||||
private static IEnumerable<ISanitizationRuleProvider> BuildProviders()
|
||||
{
|
||||
// Order matters
|
||||
return
|
||||
[
|
||||
new PiiRuleProvider(),
|
||||
new UrlRuleProvider(),
|
||||
new NetworkRuleProvider(),
|
||||
new TokenRuleProvider(),
|
||||
new ConnectionStringRuleProvider(),
|
||||
new SecretKeyValueRulesProvider(),
|
||||
new EnvironmentPropertiesRuleProvider(),
|
||||
new FilenameMaskRuleProvider(),
|
||||
new ProfilePathAndUsernameRuleProvider()
|
||||
];
|
||||
}
|
||||
|
||||
public string Sanitize(string? input) => _sanitizer.Sanitize(input);
|
||||
|
||||
public string SanitizeException(Exception? exception)
|
||||
{
|
||||
if (exception is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var fullMessage = GetFullExceptionMessage(exception);
|
||||
return Sanitize(fullMessage);
|
||||
}
|
||||
|
||||
private static string GetFullExceptionMessage(Exception exception)
|
||||
{
|
||||
List<string> messages = [];
|
||||
var current = exception;
|
||||
var depth = 0;
|
||||
|
||||
// Prevent infinite loops on pathological InnerException graphs
|
||||
while (current is not null && depth < 10)
|
||||
{
|
||||
messages.Add($"{current.GetType().Name}: {current.Message}");
|
||||
|
||||
if (!string.IsNullOrEmpty(current.StackTrace))
|
||||
{
|
||||
messages.Add($"Stack Trace: {current.StackTrace}");
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, messages);
|
||||
}
|
||||
|
||||
public void AddRule(string pattern, string replacement, string description = "")
|
||||
=> _sanitizer.AddRule(pattern, replacement, description);
|
||||
|
||||
public void RemoveRule(string description)
|
||||
=> _sanitizer.RemoveRule(description);
|
||||
|
||||
public IReadOnlyList<SanitizationRule> GetRules() => _sanitizer.GetRules();
|
||||
|
||||
public string TestRule(string input, string ruleDescription)
|
||||
=> _sanitizer.TestRule(input, ruleDescription);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
private static readonly FrozenSet<string> CommonFileStemExclusions = new[]
|
||||
{
|
||||
"settings",
|
||||
"config",
|
||||
"configuration",
|
||||
"appsettings",
|
||||
"options",
|
||||
"prefs",
|
||||
"preferences",
|
||||
"squirrel",
|
||||
"app",
|
||||
"system",
|
||||
"env",
|
||||
"environment",
|
||||
"manifest",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
const string pattern = """
|
||||
(?<full>
|
||||
(?: [A-Za-z]: )? (?: [\\/][^\\/:*?""<>|\s]+ )+ # drive-rooted or UNC-like
|
||||
| [^\\/:*?""<>|\s]+ (?: [\\/][^\\/:*?""<>|\s]+ )+ # relative with at least one sep
|
||||
)
|
||||
""";
|
||||
|
||||
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
|
||||
yield return new SanitizationRule(rx, MatchEvaluator, "Mask filename in any path");
|
||||
yield break;
|
||||
|
||||
static string MatchEvaluator(Match m)
|
||||
{
|
||||
var full = m.Groups["full"].Value;
|
||||
|
||||
var lastSep = Math.Max(full.LastIndexOf('\\'), full.LastIndexOf('/'));
|
||||
if (lastSep < 0 || lastSep == full.Length - 1)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
|
||||
var dir = full[..(lastSep + 1)];
|
||||
var file = full[(lastSep + 1)..];
|
||||
|
||||
var dot = file.LastIndexOf('.');
|
||||
var looksLikeFile = (dot > 0 && dot < file.Length - 1) || (file.StartsWith('.') && file.Length > 1);
|
||||
|
||||
if (!looksLikeFile)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
|
||||
string stem, ext;
|
||||
if (dot > 0 && dot < file.Length - 1)
|
||||
{
|
||||
stem = file[..dot];
|
||||
ext = file[dot..];
|
||||
}
|
||||
else
|
||||
{
|
||||
stem = file;
|
||||
ext = string.Empty;
|
||||
}
|
||||
|
||||
if (!ShouldMaskFileName(stem))
|
||||
{
|
||||
return dir + file;
|
||||
}
|
||||
|
||||
var masked = MaskStem(stem) + ext;
|
||||
return dir + masked;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeStem(string stem)
|
||||
{
|
||||
return stem.Replace("-", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace(".", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool ShouldMaskFileName(string stem)
|
||||
{
|
||||
return !CommonFileStemExclusions.Contains(NormalizeStem(stem));
|
||||
}
|
||||
|
||||
private static string MaskStem(string stem)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stem))
|
||||
{
|
||||
return stem;
|
||||
}
|
||||
|
||||
var keep = Math.Min(2, stem.Length);
|
||||
var maskedCount = Math.Max(1, stem.Length - keep);
|
||||
return stem[..keep] + new string('*', maskedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
public record GuardrailEventArgs(
|
||||
string RuleDescription,
|
||||
int OriginalLength,
|
||||
int ResultLength,
|
||||
double Threshold)
|
||||
{
|
||||
public double Ratio => OriginalLength > 0 ? (double)ResultLength / OriginalLength : 1.0;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal interface ISanitizationRuleProvider
|
||||
{
|
||||
IEnumerable<SanitizationRule> GetRules();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class NetworkRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(Ipv4Rx(), "[IP4_REDACTED]", "IP addresses");
|
||||
yield return new(Ipv6BracketedRx(), "[IP6_REDACTED]", "IPv6 addresses (bracketed/with port)");
|
||||
yield return new(Ipv6Rx(), "[IP6_REDACTED]", "IPv6 addresses");
|
||||
yield return new(MacAddressRx(), "[MAC_ADDRESS_REDACTED]", "MAC addresses");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex Ipv4Rx();
|
||||
|
||||
[GeneratedRegex(
|
||||
"""
|
||||
(?ix) # ignore case/whitespace
|
||||
(?<![A-F0-9:]) # left edge
|
||||
(
|
||||
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} | # 1:2:3:4:5:6:7:8
|
||||
(?:[A-F0-9]{1,4}:){1,7}: | # 1:: 1:2:...:7::
|
||||
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
|
||||
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
|
||||
:(?::[A-F0-9]{1,4}){1,7} | # ::, ::1, etc.
|
||||
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} | # IPv4 tail
|
||||
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
:(?:\d{1,3}\.){3}\d{1,3}
|
||||
)
|
||||
(?:%\w+)? # optional zone id
|
||||
(?![A-F0-9:]) # right edge
|
||||
""",
|
||||
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex Ipv6Rx();
|
||||
|
||||
[GeneratedRegex(
|
||||
"""
|
||||
(?ix)
|
||||
\[
|
||||
(
|
||||
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,7}: |
|
||||
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
|
||||
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
|
||||
:(?::[A-F0-9]{1,4}){1,7} |
|
||||
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} |
|
||||
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
:(?:\d{1,3}\.){3}\d{1,3}
|
||||
)
|
||||
(?:%\w+)? # optional zone id
|
||||
\]
|
||||
(?: : (?<port>\d{1,5}) )? # optional port
|
||||
""",
|
||||
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex Ipv6BracketedRx();
|
||||
|
||||
[GeneratedRegex(@"\b(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2}|[0-9A-Fa-f]{1,2})\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex MacAddressRx();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class PiiRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(EmailRx(), "[EMAIL_REDACTED]", "Email addresses");
|
||||
yield return new(SsnRx(), "[SSN_REDACTED]", "Social Security Numbers");
|
||||
yield return new(CreditCardRx(), "[CARD_REDACTED]", "Credit card numbers");
|
||||
|
||||
// phone number regex is the most generic, so it goes last
|
||||
// we can't make this too generic; otherwise we over-redact error codes, dates, etc.
|
||||
yield return new(PhoneRx(), "[PHONE_REDACTED]", "Phone numbers");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b[a-zA-Z0-9]([a-zA-Z0-9._%-]*[a-zA-Z0-9])?@[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex EmailRx();
|
||||
|
||||
[GeneratedRegex("""
|
||||
(?xi)
|
||||
# ---------- boundaries ----------
|
||||
(?<!\w) # not after a letter/digit/underscore
|
||||
(?<![A-Za-z0-9]-) # avoid starting inside hyphenated tokens (GUID middles, etc.)
|
||||
|
||||
# ---------- global do-not-match guards ----------
|
||||
(?! # ISO date (yyyy-mm-dd / yyyy.mm.dd / yyyy/mm/dd)
|
||||
(?:19|20)\d{2}[-./](?:0[1-9]|1[0-2])[-./](?:0[1-9]|[12]\d|3[01])\b
|
||||
)
|
||||
(?! # EU date (dd-mm-yyyy / dd.mm.yyyy / dd/mm/yyyy)
|
||||
(?:0[1-9]|[12]\d|3[01])[-./](?:0[1-9]|1[0-2])[-./](?:19|20)\d{2}\b
|
||||
)
|
||||
(?! # ISO datetime like 2025-08-24T14:32[:ss][Z|±hh:mm]
|
||||
(?:19|20)\d{2}-\d{2}-\d{2}[T\s]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?\b
|
||||
)
|
||||
(?!\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b) # IPv4 with optional :port
|
||||
(?!\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b) # GUID, lowercase
|
||||
(?!\b[0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}\b) # GUID, uppercase
|
||||
(?!\bv?\d+(?:\.\d+){2,}\b) # semantic/file versions like 1.2.3 or 10.0.22631.3448
|
||||
(?!\b(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}\b) # MAC address
|
||||
|
||||
# ---------- digit budget ----------
|
||||
(?=(?:\D*\d){7,15}) # 7–15 digits in total
|
||||
|
||||
# ---------- number body ----------
|
||||
(?:
|
||||
# A with explicit country code, allow compact digits (E.164-ish) or grouped
|
||||
(?:\+|00)[1-9]\d{0,2}
|
||||
(?:
|
||||
[\p{Zs}.\-\/]*\d{6,14}
|
||||
|
|
||||
[\p{Zs}.\-\/]* (?:\(\d{1,4}\)|\d{1,4})
|
||||
(?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6}
|
||||
)
|
||||
|
|
||||
# B no country code => require separators between blocks (avoid plain big ints)
|
||||
(?:\(\d{1,4}\)|\d{1,4})
|
||||
(?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6}
|
||||
)
|
||||
|
||||
# ---------- optional extension ----------
|
||||
(?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?<ext>\d{1,6}))?
|
||||
|
||||
(?!-\w) # don't end just before '-letter'/'-digit'
|
||||
""",
|
||||
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex PhoneRx();
|
||||
|
||||
[GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex SsnRx();
|
||||
|
||||
[GeneratedRegex(@"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex CreditCardRx();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class ProfilePathAndUsernameRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs);
|
||||
|
||||
private readonly Dictionary<string, string> _profilePaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _usernames = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly FrozenSet<string> CommonPathParts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Users", "home", "Documents", "Desktop", "AppData", "Local", "Roaming",
|
||||
"Pictures", "Videos", "Music", "Downloads", "Program Files", "Windows",
|
||||
"System32", "bin", "usr", "var", "etc", "opt", "tmp",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly FrozenSet<string> CommonWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"admin", "user", "test", "guest", "public", "system", "service",
|
||||
"default", "temp", "local", "shared", "common", "data", "config",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ProfilePathAndUsernameRuleProvider()
|
||||
{
|
||||
DetectSystemPaths();
|
||||
}
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
List<SanitizationRule> rules = [];
|
||||
|
||||
// Profile path rules (ordered longest-first)
|
||||
var orderedRules = _profilePaths
|
||||
.Where(p => !string.IsNullOrEmpty(p.Key))
|
||||
.OrderByDescending(p => p.Key.Length);
|
||||
|
||||
foreach (var profilePath in orderedRules)
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalizedPath = profilePath.Key
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar);
|
||||
var escapedPath = Regex.Escape(normalizedPath);
|
||||
|
||||
var pattern = escapedPath + @"(?:[/\\]*)";
|
||||
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout);
|
||||
|
||||
rules.Add(new(rx, profilePath.Value, $"Profile path: {profilePath}"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip problematic paths
|
||||
}
|
||||
}
|
||||
|
||||
// Username rules
|
||||
foreach (var username in _usernames.Where(u => !string.IsNullOrEmpty(u) && u.Length > 2))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsLikelyUsername(username))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rx = new Regex(@"\b" + Regex.Escape(username) + @"\b", SanitizerDefaults.DefaultOptions, DefaultTimeout);
|
||||
rules.Add(new(rx, "[USERNAME_REDACTED]", $"Username: {username}"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip problematic usernames
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> GetDetectedProfilePaths() => _profilePaths;
|
||||
|
||||
public IReadOnlyCollection<string> GetDetectedUsernames() => _usernames;
|
||||
|
||||
private void DetectSystemPaths()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(userProfile) && Directory.Exists(userProfile))
|
||||
{
|
||||
_profilePaths.Add(userProfile, "[USER_PROFILE_DIR]");
|
||||
var username = Path.GetFileName(userProfile);
|
||||
if (!string.IsNullOrEmpty(username) && username.Length > 2)
|
||||
{
|
||||
_usernames.Add(username);
|
||||
}
|
||||
}
|
||||
|
||||
Environment.SpecialFolder[] profileFolders =
|
||||
[
|
||||
Environment.SpecialFolder.ApplicationData,
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolder.Desktop,
|
||||
Environment.SpecialFolder.MyDocuments,
|
||||
Environment.SpecialFolder.MyPictures,
|
||||
Environment.SpecialFolder.MyVideos,
|
||||
Environment.SpecialFolder.MyMusic,
|
||||
Environment.SpecialFolder.StartMenu,
|
||||
Environment.SpecialFolder.Startup,
|
||||
Environment.SpecialFolder.DesktopDirectory
|
||||
];
|
||||
|
||||
foreach (var folder in profileFolders)
|
||||
{
|
||||
var dir = Environment.GetFolderPath(folder);
|
||||
if (string.IsNullOrEmpty(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var added = _profilePaths.TryAdd(dir, $"[{folder.ToString().ToUpperInvariant()}_DIR]");
|
||||
if (!added)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
string[] envVars = ["USERPROFILE", "HOME", "OneDrive", "OneDriveCommercial"];
|
||||
foreach (var envVar in envVars)
|
||||
{
|
||||
var envPath = Environment.GetEnvironmentVariable(envVar);
|
||||
if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
|
||||
{
|
||||
_profilePaths.TryAdd(envPath, $"[{envVar.ToUpperInvariant()}_DIR]");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Error detecting system profile paths and usernames", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLikelyUsername(string username) =>
|
||||
!CommonWords.Contains(username) &&
|
||||
username.Length is >= 3 and <= 50 &&
|
||||
!username.All(char.IsDigit);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal static class SanitizerDefaults
|
||||
{
|
||||
public const RegexOptions DefaultOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled;
|
||||
public const int DefaultMatchTimeoutMs = 100;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class SecretKeyValueRulesProvider : ISanitizationRuleProvider
|
||||
{
|
||||
// Central list of common secret keys/phrases to redact when found in key=value pairs.
|
||||
private static readonly FrozenSet<string> SecretKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Core passwords/secrets
|
||||
"password",
|
||||
"passphrase",
|
||||
"passwd",
|
||||
"pwd",
|
||||
|
||||
// Tokens
|
||||
"token",
|
||||
"access token",
|
||||
"refresh token",
|
||||
"id token",
|
||||
"auth token",
|
||||
"session token",
|
||||
"bearer token",
|
||||
"personal access token",
|
||||
"pat",
|
||||
|
||||
// API / client credentials
|
||||
"api key",
|
||||
"api secret",
|
||||
"x api key",
|
||||
"client id",
|
||||
"client secret",
|
||||
"x client id",
|
||||
"x client secret",
|
||||
"consumer secret",
|
||||
"service principal secret",
|
||||
|
||||
// Cloud & platform (Azure/AppInsights/etc.)
|
||||
"subscription key",
|
||||
"instrumentation key",
|
||||
"account key",
|
||||
"storage account key",
|
||||
"shared access key",
|
||||
"shared access signature",
|
||||
"SAS token",
|
||||
|
||||
// Connection strings (often surfaced in exception messages)
|
||||
"connection string",
|
||||
"conn string",
|
||||
"storage connection string",
|
||||
|
||||
// Certificates & crypto
|
||||
"private key",
|
||||
"certificate password",
|
||||
"client certificate password",
|
||||
"pfx password",
|
||||
|
||||
// AWS common keys
|
||||
"aws access key id",
|
||||
"aws secret access key",
|
||||
"aws session token",
|
||||
|
||||
// Optional service aliases
|
||||
"cosmos db key",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return BuildSecretKeyValueRule(
|
||||
SecretKeys,
|
||||
timeout: TimeSpan.FromSeconds(5),
|
||||
starEverything: true);
|
||||
}
|
||||
|
||||
private static SanitizationRule BuildSecretKeyValueRule(
|
||||
IEnumerable<string> keys,
|
||||
RegexOptions? options = null,
|
||||
TimeSpan? timeout = null,
|
||||
string label = "[REDACTED]",
|
||||
bool treatDashUnderscoreAsSpace = true,
|
||||
string separatorsClass = "[:=]", // char class for separators
|
||||
string unquotedStopClass = "\\s",
|
||||
bool starEverything = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keys);
|
||||
|
||||
// Between-word matcher for keys: "api key" -> "api\s*key" (optionally treating _/- as "space")
|
||||
var between = treatDashUnderscoreAsSpace ? @"(?:\s|[_-])*" : @"\s*";
|
||||
|
||||
var patterns = new List<string>();
|
||||
|
||||
foreach (var raw in keys)
|
||||
{
|
||||
var key = raw?.Trim();
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starEverything && key is not ['*', ..])
|
||||
{
|
||||
key = "*" + key;
|
||||
}
|
||||
|
||||
if (key is ['*', .. var tail])
|
||||
{
|
||||
// Wildcard prefix: allow one non-space token + optional "-" or "_" before the remainder.
|
||||
// Matches: "api key", "api-key", "azure-api-key", "user_api_key"
|
||||
var remainder = tail.Trim();
|
||||
if (remainder.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rem = Normalize(remainder, between);
|
||||
patterns.Add($@"(?:(?>[A-Za-z0-9_]{{1,128}}[_-]))?{rem}");
|
||||
}
|
||||
else
|
||||
{
|
||||
patterns.Add(Normalize(key, between));
|
||||
}
|
||||
}
|
||||
|
||||
if (patterns.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("No non-empty keys provided.", nameof(keys));
|
||||
}
|
||||
|
||||
var keysAlt = string.Join("|", patterns);
|
||||
|
||||
var pattern =
|
||||
$"""
|
||||
# Negative lookbehind to ensure the key is not part of a larger word
|
||||
(?<![A-Za-z0-9])
|
||||
# Match and capture the key (from the provided list)
|
||||
(?<key>(?:{keysAlt}))
|
||||
# Negative lookahead to ensure the key is not part of a larger word
|
||||
(?![A-Za-z0-9])
|
||||
# Optional whitespace between key and separator
|
||||
\s*
|
||||
# Separator (e.g., ':' or '=')
|
||||
(?<sep>{separatorsClass})
|
||||
# Optional whitespace after separator
|
||||
\s*
|
||||
# Match and capture the value, supporting quoted or unquoted values
|
||||
(?:
|
||||
# Quoted value: match opening quote, value, and closing quote
|
||||
(?<q>["'])(?<val>[^"']+)\k<q>
|
||||
|
|
||||
# Unquoted value: match up to the next whitespace
|
||||
(?<val>[^{unquotedStopClass}]+)
|
||||
)
|
||||
""";
|
||||
|
||||
var rx = new Regex(
|
||||
pattern,
|
||||
(options ?? (RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) | RegexOptions.IgnorePatternWhitespace,
|
||||
timeout ?? TimeSpan.FromMilliseconds(1000));
|
||||
|
||||
var replacement = @"${key}${sep} ${q}" + label + @"${q}";
|
||||
return new SanitizationRule(rx, replacement, "Sensitive key/value pairs");
|
||||
|
||||
static string Normalize(string s, string betweenSep)
|
||||
=> Regex.Escape(s).Replace("\\ ", betweenSep);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
/// <summary>
|
||||
/// Generic text sanitizer that applies a sequence of regex-based rules over input text.
|
||||
/// </summary>
|
||||
internal sealed class TextSanitizer : ITextSanitizer
|
||||
{
|
||||
// Default guardrail: sanitized text must retain at least 30% of the original length
|
||||
private const double DefaultGuardrailThreshold = 0.3;
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs);
|
||||
|
||||
private readonly List<SanitizationRule> _rules = [];
|
||||
private readonly double _guardrailThreshold;
|
||||
private readonly Action<GuardrailEventArgs>? _onGuardrailTriggered;
|
||||
|
||||
public TextSanitizer(
|
||||
double guardrailThreshold = DefaultGuardrailThreshold,
|
||||
Action<GuardrailEventArgs>? onGuardrailTriggered = null)
|
||||
{
|
||||
_guardrailThreshold = guardrailThreshold;
|
||||
_onGuardrailTriggered = onGuardrailTriggered;
|
||||
}
|
||||
|
||||
public TextSanitizer(
|
||||
IEnumerable<ISanitizationRuleProvider> providers,
|
||||
double guardrailThreshold = DefaultGuardrailThreshold,
|
||||
Action<GuardrailEventArgs>? onGuardrailTriggered = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providers);
|
||||
_guardrailThreshold = guardrailThreshold;
|
||||
_onGuardrailTriggered = onGuardrailTriggered;
|
||||
|
||||
foreach (var p in providers)
|
||||
{
|
||||
try
|
||||
{
|
||||
_rules.AddRange(p.GetRules());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort; ignore provider errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string Sanitize(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = input;
|
||||
|
||||
foreach (var rule in _rules)
|
||||
{
|
||||
try
|
||||
{
|
||||
var previous = result;
|
||||
|
||||
result = rule.Evaluator is null
|
||||
? rule.Regex.Replace(previous, rule.Replacement!)
|
||||
: rule.Regex.Replace(previous, rule.Evaluator);
|
||||
|
||||
if (result.Length < previous.Length * _guardrailThreshold)
|
||||
{
|
||||
_onGuardrailTriggered?.Invoke(new GuardrailEventArgs(
|
||||
rule.Description,
|
||||
previous.Length,
|
||||
result.Length,
|
||||
_guardrailThreshold));
|
||||
result = previous; // Guardrail
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
// Ignore timeouts; keep the original input
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore other exceptions; keep the original input
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void AddRule(string pattern, string replacement, string description = "")
|
||||
{
|
||||
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout);
|
||||
_rules.Add(new SanitizationRule(rx, replacement, description));
|
||||
}
|
||||
|
||||
public void RemoveRule(string description)
|
||||
{
|
||||
_rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public IReadOnlyList<SanitizationRule> GetRules() => _rules.AsReadOnly();
|
||||
|
||||
public string TestRule(string input, string ruleDescription)
|
||||
{
|
||||
var rule = _rules.FirstOrDefault(r => r.Description.Contains(ruleDescription, StringComparison.OrdinalIgnoreCase));
|
||||
if (rule.Regex is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (rule.Evaluator is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Evaluator);
|
||||
}
|
||||
|
||||
if (rule.Replacement is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Replacement);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore exceptions; return original input
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class TokenRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(JwtRx(), "[JWT_REDACTED]", "JSON Web Tokens (JWT)");
|
||||
yield return new(TokenRx(), "[TOKEN_REDACTED]", "Potential API keys/tokens");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex JwtRx();
|
||||
|
||||
[GeneratedRegex(@"\b[A-Za-z0-9]{32,128}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex TokenRx();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class UrlRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(UrlRx(), "[URL_REDACTED]", "URLs");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b(?:https?|ftp|ftps|file|jdbc|ldap|mailto)://[^\s<>""'{}\[\]\\^`|]+",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex UrlRx();
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.CmdPal.Core.Common;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.ViewModels;
|
||||
|
||||
internal static class BatchUpdateManager
|
||||
{
|
||||
private const int ExpectedBatchSize = 32;
|
||||
|
||||
// 30 ms chosen empirically to balance responsiveness and batching:
|
||||
// - Keeps perceived latency low (< ~50 ms) for user-visible updates.
|
||||
// - Still allows multiple COM/background events to be coalesced into a single batch.
|
||||
private static readonly TimeSpan BatchDelay = TimeSpan.FromMilliseconds(30);
|
||||
private static readonly ConcurrentQueue<IBatchUpdateTarget> DirtyQueue = [];
|
||||
private static readonly Timer Timer = new(static _ => Flush(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
|
||||
private static InterlockedBoolean _isFlushScheduled;
|
||||
|
||||
/// <summary>
|
||||
/// Enqueue a target for batched processing. Safe to call from any thread (including COM callbacks).
|
||||
/// </summary>
|
||||
public static void Queue(IBatchUpdateTarget target)
|
||||
{
|
||||
if (!target.TryMarkBatchQueued())
|
||||
{
|
||||
return; // already queued in current batch window
|
||||
}
|
||||
|
||||
DirtyQueue.Enqueue(target);
|
||||
TryScheduleFlush();
|
||||
}
|
||||
|
||||
private static void TryScheduleFlush()
|
||||
{
|
||||
if (!_isFlushScheduled.Set())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DirtyQueue.IsEmpty)
|
||||
{
|
||||
_isFlushScheduled.Clear();
|
||||
|
||||
if (DirtyQueue.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isFlushScheduled.Set())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Timer.Change(BatchDelay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_isFlushScheduled.Clear();
|
||||
CoreLogger.LogError("Failed to arm batch timer.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Flush()
|
||||
{
|
||||
try
|
||||
{
|
||||
var drained = new List<IBatchUpdateTarget>(ExpectedBatchSize);
|
||||
while (DirtyQueue.TryDequeue(out var item))
|
||||
{
|
||||
drained.Add(item);
|
||||
}
|
||||
|
||||
if (drained.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// LOAD BEARING:
|
||||
// ApplyPendingUpdates must run on a background thread.
|
||||
// The VM itself is responsible for marshaling UI notifications to its _uiScheduler.
|
||||
ApplyBatch(drained);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't kill the timer thread.
|
||||
CoreLogger.LogError("Batch flush failed.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isFlushScheduled.Clear();
|
||||
TryScheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyBatch(List<IBatchUpdateTarget> items)
|
||||
{
|
||||
// Runs on the Timer callback thread (ThreadPool). That's fine: background work only.
|
||||
foreach (var item in items)
|
||||
{
|
||||
// Allow re-queueing immediately if more COM events arrive during apply.
|
||||
item.ClearBatchQueued();
|
||||
|
||||
try
|
||||
{
|
||||
item.ApplyPendingUpdates();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to apply pending updates for a batched target.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IBatchUpdateTarget
|
||||
{
|
||||
/// <summary>UI scheduler (used by targets internally for UI marshaling). Kept here for diagnostics / consistency.</summary>
|
||||
TaskScheduler UIScheduler { get; }
|
||||
|
||||
/// <summary>Apply any coalesced updates. Must be safe to call on a background thread.</summary>
|
||||
void ApplyPendingUpdates();
|
||||
|
||||
/// <summary>De-dupe gate: returns true only for the first enqueue until cleared.</summary>
|
||||
bool TryMarkBatchQueued();
|
||||
|
||||
/// <summary>Clear the de-dupe gate so the item can be queued again.</summary>
|
||||
void ClearBatchQueued();
|
||||
}
|
||||
@@ -2,36 +2,99 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.CmdPal.Core.Common;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.ViewModels;
|
||||
|
||||
public abstract partial class ExtensionObjectViewModel : ObservableObject
|
||||
public abstract partial class ExtensionObjectViewModel : ObservableObject, IBatchUpdateTarget, IBackgroundPropertyChangedNotification
|
||||
{
|
||||
public WeakReference<IPageContext> PageContext { get; set; }
|
||||
private const int InitialPropertyBatchingBufferSize = 16;
|
||||
|
||||
internal ExtensionObjectViewModel(IPageContext? context)
|
||||
{
|
||||
var realContext = context ?? (this is IPageContext c ? c : throw new ArgumentException("You need to pass in an IErrorContext"));
|
||||
PageContext = new(realContext);
|
||||
}
|
||||
// Raised on the background thread before UI notifications. It's raised on the background thread to prevent
|
||||
// blocking the COM proxy.
|
||||
public event PropertyChangedEventHandler? PropertyChangedBackground;
|
||||
|
||||
internal ExtensionObjectViewModel(WeakReference<IPageContext> context)
|
||||
{
|
||||
PageContext = context;
|
||||
}
|
||||
private readonly ConcurrentQueue<string> _pendingProps = [];
|
||||
|
||||
public async virtual Task InitializePropertiesAsync()
|
||||
private readonly TaskScheduler _uiScheduler;
|
||||
|
||||
private InterlockedBoolean _batchQueued;
|
||||
|
||||
public WeakReference<IPageContext> PageContext { get; private set; } = null!;
|
||||
|
||||
TaskScheduler IBatchUpdateTarget.UIScheduler => _uiScheduler;
|
||||
|
||||
void IBatchUpdateTarget.ApplyPendingUpdates() => ApplyPendingUpdates();
|
||||
|
||||
bool IBatchUpdateTarget.TryMarkBatchQueued() => _batchQueued.Set();
|
||||
|
||||
void IBatchUpdateTarget.ClearBatchQueued() => _batchQueued.Clear();
|
||||
|
||||
private protected ExtensionObjectViewModel(TaskScheduler scheduler)
|
||||
{
|
||||
var t = new Task(() =>
|
||||
if (this is not IPageContext)
|
||||
{
|
||||
SafeInitializePropertiesSynchronous();
|
||||
});
|
||||
t.Start();
|
||||
await t;
|
||||
throw new InvalidOperationException($"Constructor overload without IPageContext can only be used when the derived class implements IPageContext. Type: {GetType().FullName}");
|
||||
}
|
||||
|
||||
_uiScheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
|
||||
|
||||
// Defer PageContext assignment - derived constructor MUST call InitializePageContext()
|
||||
// or we set it lazily on first access
|
||||
}
|
||||
|
||||
private protected ExtensionObjectViewModel(IPageContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
PageContext = new WeakReference<IPageContext>(context);
|
||||
_uiScheduler = context.Scheduler;
|
||||
|
||||
LogIfDefaultScheduler();
|
||||
}
|
||||
|
||||
private protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contextRef);
|
||||
|
||||
if (!contextRef.TryGetTarget(out var context))
|
||||
{
|
||||
throw new ArgumentException("IPageContext must be alive when creating view models.", nameof(contextRef));
|
||||
}
|
||||
|
||||
PageContext = contextRef;
|
||||
_uiScheduler = context.Scheduler;
|
||||
|
||||
LogIfDefaultScheduler();
|
||||
}
|
||||
|
||||
protected void InitializeSelfAsPageContext()
|
||||
{
|
||||
if (this is not IPageContext self)
|
||||
{
|
||||
throw new InvalidOperationException("This method can only be called when the class implements IPageContext.");
|
||||
}
|
||||
|
||||
PageContext = new WeakReference<IPageContext>(self);
|
||||
}
|
||||
|
||||
private void LogIfDefaultScheduler()
|
||||
{
|
||||
if (_uiScheduler == TaskScheduler.Default)
|
||||
{
|
||||
CoreLogger.LogDebug($"ExtensionObjectViewModel created with TaskScheduler.Default. Type: {GetType().FullName}");
|
||||
}
|
||||
}
|
||||
|
||||
public virtual Task InitializePropertiesAsync()
|
||||
=> Task.Run(SafeInitializePropertiesSynchronous);
|
||||
|
||||
public void SafeInitializePropertiesSynchronous()
|
||||
{
|
||||
try
|
||||
@@ -46,49 +109,151 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject
|
||||
|
||||
public abstract void InitializeProperties();
|
||||
|
||||
protected void UpdateProperty(string propertyName)
|
||||
{
|
||||
DoOnUiThread(() => OnPropertyChanged(propertyName));
|
||||
}
|
||||
protected void UpdateProperty(string propertyName) => MarkPropertyDirty(propertyName);
|
||||
|
||||
protected void UpdateProperty(string propertyName1, string propertyName2)
|
||||
{
|
||||
DoOnUiThread(() =>
|
||||
{
|
||||
OnPropertyChanged(propertyName1);
|
||||
OnPropertyChanged(propertyName2);
|
||||
});
|
||||
}
|
||||
|
||||
protected void UpdateProperty(string propertyName1, string propertyName2, string propertyName3)
|
||||
{
|
||||
DoOnUiThread(() =>
|
||||
{
|
||||
OnPropertyChanged(propertyName1);
|
||||
OnPropertyChanged(propertyName2);
|
||||
OnPropertyChanged(propertyName3);
|
||||
});
|
||||
MarkPropertyDirty(propertyName1);
|
||||
MarkPropertyDirty(propertyName2);
|
||||
}
|
||||
|
||||
protected void UpdateProperty(params string[] propertyNames)
|
||||
{
|
||||
DoOnUiThread(() =>
|
||||
foreach (var p in propertyNames)
|
||||
{
|
||||
foreach (var propertyName in propertyNames)
|
||||
{
|
||||
OnPropertyChanged(propertyName);
|
||||
}
|
||||
});
|
||||
MarkPropertyDirty(p);
|
||||
}
|
||||
}
|
||||
|
||||
internal void MarkPropertyDirty(string? propertyName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(propertyName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// We should re-consider if this worth deduping
|
||||
_pendingProps.Enqueue(propertyName);
|
||||
BatchUpdateManager.Queue(this);
|
||||
}
|
||||
|
||||
public void ApplyPendingUpdates()
|
||||
{
|
||||
((IBatchUpdateTarget)this).ClearBatchQueued();
|
||||
|
||||
var buffer = ArrayPool<string>.Shared.Rent(InitialPropertyBatchingBufferSize);
|
||||
var count = 0;
|
||||
var transferred = false;
|
||||
|
||||
try
|
||||
{
|
||||
while (_pendingProps.TryDequeue(out var name))
|
||||
{
|
||||
if (count == buffer.Length)
|
||||
{
|
||||
var bigger = ArrayPool<string>.Shared.Rent(buffer.Length * 2);
|
||||
Array.Copy(buffer, bigger, buffer.Length);
|
||||
ArrayPool<string>.Shared.Return(buffer, clearArray: true);
|
||||
buffer = bigger;
|
||||
}
|
||||
|
||||
buffer[count++] = name;
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Background subscribers (must be raised before UI notifications).
|
||||
var propertyChangedEventHandler = PropertyChangedBackground;
|
||||
if (propertyChangedEventHandler is not null)
|
||||
{
|
||||
RaiseBackground(propertyChangedEventHandler, this, buffer, count);
|
||||
}
|
||||
|
||||
// 2) UI-facing PropertyChanged: ALWAYS marshal to UI scheduler.
|
||||
// Hand-off pooled buffer to UI task (UI task returns it).
|
||||
//
|
||||
// It would be lovely to do nothing if no one is actually listening on PropertyChanged,
|
||||
// but ObservableObject doesn't expose that information.
|
||||
_ = Task.Factory.StartNew(
|
||||
static state =>
|
||||
{
|
||||
var p = (UiBatch)state!;
|
||||
try
|
||||
{
|
||||
p.Owner.RaiseUi(p.Names, p.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to raise property change notifications on UI thread.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<string>.Shared.Return(p.Names, clearArray: true);
|
||||
}
|
||||
},
|
||||
new UiBatch(this, buffer, count),
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.DenyChildAttach,
|
||||
_uiScheduler);
|
||||
|
||||
transferred = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to apply pending property updates.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!transferred)
|
||||
{
|
||||
ArrayPool<string>.Shared.Return(buffer, clearArray: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseUi(string[] names, int count)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
OnPropertyChanged(Args(names[i]));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RaiseBackground(PropertyChangedEventHandler handlers, object sender, string[] names, int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
handlers(sender, Args(names[i]));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to raise PropertyChangedBackground notifications.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record UiBatch(ExtensionObjectViewModel Owner, string[] Names, int Count);
|
||||
|
||||
protected void ShowException(Exception ex, string? extensionHint = null)
|
||||
{
|
||||
if (PageContext.TryGetTarget(out var pageContext))
|
||||
{
|
||||
pageContext.ShowException(ex, extensionHint);
|
||||
}
|
||||
else
|
||||
{
|
||||
CoreLogger.LogError("Failed to show exception because PageContext is no longer available.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static PropertyChangedEventArgs Args(string name) => new(name);
|
||||
|
||||
protected void DoOnUiThread(Action action)
|
||||
{
|
||||
if (PageContext.TryGetTarget(out var pageContext))
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a notification mechanism for property changes that fires
|
||||
/// synchronously on the calling thread.
|
||||
/// </summary>
|
||||
public interface IBackgroundPropertyChangedNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Occurs when the value of a property changes.
|
||||
/// </summary>
|
||||
event PropertyChangedEventHandler? PropertyChangedBackground;
|
||||
}
|
||||
@@ -77,11 +77,11 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
|
||||
public IconInfoViewModel Icon { get; protected set; }
|
||||
|
||||
public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost)
|
||||
: base((IPageContext?)null)
|
||||
: base(scheduler)
|
||||
{
|
||||
InitializeSelfAsPageContext();
|
||||
_pageModel = new(model);
|
||||
Scheduler = scheduler;
|
||||
PageContext = new(this);
|
||||
ExtensionHost = extensionHost;
|
||||
Icon = new(null);
|
||||
|
||||
|
||||
@@ -43,4 +43,10 @@
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>$(AssemblyName).UnitTests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,6 +6,7 @@ using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.Core.ViewModels.Models;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -23,6 +24,8 @@ public sealed class CommandProviderWrapper
|
||||
|
||||
private readonly TaskScheduler _taskScheduler;
|
||||
|
||||
private readonly ICommandProviderCache? _commandProviderCache;
|
||||
|
||||
public TopLevelViewModel[] TopLevelItems { get; private set; } = [];
|
||||
|
||||
public TopLevelViewModel[] FallbackItems { get; private set; } = [];
|
||||
@@ -43,13 +46,7 @@ public sealed class CommandProviderWrapper
|
||||
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
public string ProviderId
|
||||
{
|
||||
get
|
||||
{
|
||||
return string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
|
||||
}
|
||||
}
|
||||
public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
|
||||
|
||||
public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread)
|
||||
{
|
||||
@@ -77,9 +74,11 @@ public sealed class CommandProviderWrapper
|
||||
Logger.LogDebug($"Initialized command provider {ProviderId}");
|
||||
}
|
||||
|
||||
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread)
|
||||
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread, ICommandProviderCache commandProviderCache)
|
||||
{
|
||||
_taskScheduler = mainThread;
|
||||
_commandProviderCache = commandProviderCache;
|
||||
|
||||
Extension = extension;
|
||||
ExtensionHost = new CommandPaletteHost(extension);
|
||||
if (!Extension.IsRunning())
|
||||
@@ -128,30 +127,31 @@ public sealed class CommandProviderWrapper
|
||||
if (!isValid)
|
||||
{
|
||||
IsActive = false;
|
||||
RecallFromCache();
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = serviceProvider.GetService<SettingsModel>()!;
|
||||
|
||||
IsActive = GetProviderSettings(settings).IsEnabled;
|
||||
var providerSettings = GetProviderSettings(settings);
|
||||
IsActive = providerSettings.IsEnabled;
|
||||
if (!IsActive)
|
||||
{
|
||||
RecallFromCache();
|
||||
return;
|
||||
}
|
||||
|
||||
ICommandItem[]? commands = null;
|
||||
IFallbackCommandItem[]? fallbacks = null;
|
||||
|
||||
var displayInfoInitialized = false;
|
||||
try
|
||||
{
|
||||
var model = _commandProvider.Unsafe!;
|
||||
|
||||
Task<ICommandItem[]> t = new(model.TopLevelCommands);
|
||||
t.Start();
|
||||
commands = await t.ConfigureAwait(false);
|
||||
Task<ICommandItem[]> loadTopLevelCommandsTask = new(model.TopLevelCommands);
|
||||
loadTopLevelCommandsTask.Start();
|
||||
var commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
|
||||
|
||||
// On a BG thread here
|
||||
fallbacks = model.FallbackCommands();
|
||||
var fallbacks = model.FallbackCommands();
|
||||
|
||||
if (model is ICommandProvider2 two)
|
||||
{
|
||||
@@ -162,6 +162,13 @@ public sealed class CommandProviderWrapper
|
||||
DisplayName = model.DisplayName;
|
||||
Icon = new(model.Icon);
|
||||
Icon.InitializeProperties();
|
||||
displayInfoInitialized = true;
|
||||
|
||||
// Update cached display name
|
||||
if (_commandProviderCache is not null && Extension?.ExtensionUniqueId is not null)
|
||||
{
|
||||
_commandProviderCache.Memorize(Extension.ExtensionUniqueId, new CommandProviderCacheItem(model.DisplayName));
|
||||
}
|
||||
|
||||
// Note: explicitly not InitializeProperties()ing the settings here. If
|
||||
// we do that, then we'd regress GH #38321
|
||||
@@ -177,6 +184,25 @@ public sealed class CommandProviderWrapper
|
||||
Logger.LogError("Failed to load commands from extension");
|
||||
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
|
||||
Logger.LogError(e.ToString());
|
||||
|
||||
if (!displayInfoInitialized)
|
||||
{
|
||||
RecallFromCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecallFromCache()
|
||||
{
|
||||
var cached = _commandProviderCache?.Recall(ProviderId);
|
||||
if (cached is not null)
|
||||
{
|
||||
DisplayName = cached.DisplayName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DisplayName))
|
||||
{
|
||||
DisplayName = Extension?.PackageDisplayName ?? Extension?.PackageFamilyName ?? ProviderId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +211,7 @@ public sealed class CommandProviderWrapper
|
||||
var settings = serviceProvider.GetService<SettingsModel>()!;
|
||||
var providerSettings = GetProviderSettings(settings);
|
||||
|
||||
Func<ICommandItem?, bool, TopLevelViewModel> makeAndAdd = (ICommandItem? i, bool fallback) =>
|
||||
var makeAndAdd = (ICommandItem? i, bool fallback) =>
|
||||
{
|
||||
CommandItemViewModel commandItemViewModel = new(new(i), pageContext);
|
||||
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i);
|
||||
|
||||
@@ -19,7 +19,7 @@ public partial class OpenSettingsCommand : InvokableCommand
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,4 @@
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Messages;
|
||||
|
||||
public record OpenSettingsMessage()
|
||||
{
|
||||
}
|
||||
public record OpenSettingsMessage(string SettingsPageTag = "");
|
||||
|
||||
@@ -14,11 +14,13 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class ProviderSettingsViewModel : ObservableObject
|
||||
{
|
||||
private static readonly IconInfoViewModel EmptyIcon = new(null);
|
||||
|
||||
private readonly CommandProviderWrapper _provider;
|
||||
private readonly ProviderSettings _providerSettings;
|
||||
private readonly SettingsModel _settings;
|
||||
|
||||
private readonly Lock _initializeSettingsLock = new();
|
||||
|
||||
private Task? _initializeSettingsTask;
|
||||
|
||||
public ProviderSettingsViewModel(
|
||||
@@ -43,7 +45,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
HasFallbackCommands ?
|
||||
$"{ExtensionName}, {TopLevelCommands.Count} commands, {_provider.FallbackItems?.Length} fallback commands" :
|
||||
$"{ExtensionName}, {TopLevelCommands.Count} commands" :
|
||||
Resources.builtin_disabled_extension;
|
||||
$"{ExtensionName}, {Resources.builtin_disabled_extension}";
|
||||
|
||||
[MemberNotNullWhen(true, nameof(Extension))]
|
||||
public bool IsFromExtension => _provider.Extension is not null;
|
||||
@@ -52,7 +54,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
|
||||
public string ExtensionVersion => IsFromExtension ? $"{Extension.Version.Major}.{Extension.Version.Minor}.{Extension.Version.Build}.{Extension.Version.Revision}" : string.Empty;
|
||||
|
||||
public IconInfoViewModel Icon => _provider.Icon;
|
||||
public IconInfoViewModel Icon => IsEnabled ? _provider.Icon : EmptyIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool LoadingSettings { get; set; }
|
||||
@@ -69,6 +71,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
|
||||
OnPropertyChanged(nameof(IsEnabled));
|
||||
OnPropertyChanged(nameof(ExtensionSubtext));
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
}
|
||||
|
||||
if (value == true)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
internal sealed class CommandProviderCacheContainer
|
||||
{
|
||||
public Dictionary<string, CommandProviderCacheItem> Cache { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
public record CommandProviderCacheItem(string DisplayName);
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
[JsonSerializable(typeof(CommandProviderCacheItem))]
|
||||
[JsonSerializable(typeof(Dictionary<string, CommandProviderCacheItem>))]
|
||||
[JsonSerializable(typeof(CommandProviderCacheContainer))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = false)]
|
||||
internal sealed partial class CommandProviderCacheSerializationContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
public sealed partial class DefaultCommandProviderCache : ICommandProviderCache, IDisposable
|
||||
{
|
||||
private const string CacheFileName = "commandProviderCache.json";
|
||||
|
||||
private readonly Dictionary<string, CommandProviderCacheItem> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
|
||||
private readonly SupersedingAsyncGate _saveGate;
|
||||
|
||||
public DefaultCommandProviderCache()
|
||||
{
|
||||
_saveGate = new SupersedingAsyncGate(async _ => await TrySaveAsync().ConfigureAwait(false));
|
||||
TryLoad();
|
||||
}
|
||||
|
||||
public void Memorize(string providerId, CommandProviderCacheItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerId);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_cache[providerId] = item;
|
||||
}
|
||||
|
||||
_ = _saveGate.ExecuteAsync();
|
||||
}
|
||||
|
||||
public CommandProviderCacheItem? Recall(string providerId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerId);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_cache.TryGetValue(providerId, out var item);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetCacheFilePath()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, CacheFileName);
|
||||
}
|
||||
|
||||
private void TryLoad()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = GetCacheFilePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var loaded = JsonSerializer.Deserialize(
|
||||
json,
|
||||
CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!);
|
||||
if (loaded?.Cache is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cache.Clear();
|
||||
foreach (var kvp in loaded.Cache)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(kvp.Key) && kvp.Value is not null)
|
||||
{
|
||||
_cache[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to load command provider cache: ", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TrySaveAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Dictionary<string, CommandProviderCacheItem> snapshot;
|
||||
lock (_sync)
|
||||
{
|
||||
snapshot = new Dictionary<string, CommandProviderCacheItem>(_cache, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var container = new CommandProviderCacheContainer
|
||||
{
|
||||
Cache = snapshot,
|
||||
};
|
||||
|
||||
var path = GetCacheFilePath();
|
||||
var json = JsonSerializer.Serialize(container, CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!);
|
||||
await File.WriteAllTextAsync(path, json).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to save command provider cache: ", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_saveGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
public interface ICommandProviderCache
|
||||
{
|
||||
void Memorize(string providerId, CommandProviderCacheItem item);
|
||||
|
||||
CommandProviderCacheItem? Recall(string providerId);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -25,6 +26,7 @@ public partial class TopLevelCommandManager : ObservableObject,
|
||||
IDisposable
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ICommandProviderCache _commandProviderCache;
|
||||
private readonly TaskScheduler _taskScheduler;
|
||||
|
||||
private readonly List<CommandProviderWrapper> _builtInCommands = [];
|
||||
@@ -34,9 +36,10 @@ public partial class TopLevelCommandManager : ObservableObject,
|
||||
|
||||
TaskScheduler IPageContext.Scheduler => _taskScheduler;
|
||||
|
||||
public TopLevelCommandManager(IServiceProvider serviceProvider)
|
||||
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_commandProviderCache = commandProviderCache;
|
||||
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
|
||||
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
|
||||
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
|
||||
@@ -319,7 +322,7 @@ public partial class TopLevelCommandManager : ObservableObject,
|
||||
try
|
||||
{
|
||||
await extension.StartExtensionAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
return new CommandProviderWrapper(extension, _taskScheduler);
|
||||
return new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -199,7 +199,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
|
||||
_fallbackId = fallback.Id;
|
||||
}
|
||||
|
||||
item.PropertyChanged += Item_PropertyChanged;
|
||||
item.PropertyChangedBackground += Item_PropertyChanged;
|
||||
|
||||
// UpdateAlias();
|
||||
// UpdateHotkey();
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace Microsoft.CmdPal.UI;
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
public partial class App : Application, IDisposable
|
||||
{
|
||||
private readonly GlobalErrorHandler _globalErrorHandler = new();
|
||||
|
||||
@@ -67,7 +67,7 @@ public partial class App : Application
|
||||
public App()
|
||||
{
|
||||
#if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER
|
||||
_globalErrorHandler.Register(this);
|
||||
_globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default);
|
||||
#endif
|
||||
|
||||
Services = ConfigureServices();
|
||||
@@ -178,6 +178,7 @@ public partial class App : Application
|
||||
services.AddSingleton(state);
|
||||
|
||||
// Services
|
||||
services.AddSingleton<ICommandProviderCache, DefaultCommandProviderCache>();
|
||||
services.AddSingleton<TopLevelCommandManager>();
|
||||
services.AddSingleton<AliasManager>();
|
||||
services.AddSingleton<HotkeyManager>();
|
||||
@@ -203,4 +204,11 @@ public partial class App : Application
|
||||
services.AddSingleton<ShellViewModel>();
|
||||
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_globalErrorHandler.Dispose();
|
||||
EtwTrace.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -128,7 +128,7 @@ public sealed partial class CommandBar : UserControl,
|
||||
|
||||
private void SettingsIcon_Clicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
}
|
||||
|
||||
private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e)
|
||||
|
||||
@@ -203,6 +203,12 @@
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- More section -->
|
||||
<TextBlock Style="{ThemeResource SettingsSectionHeaderTextBlockStyle}" Text="More" />
|
||||
<Border>
|
||||
<Button Command="{x:Bind ViewModel.OpenInternalToolsCommand}" Content="Open internal tools" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Reports;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
@@ -15,14 +15,22 @@ namespace Microsoft.CmdPal.UI.Helpers;
|
||||
/// <summary>
|
||||
/// Global error handler for Command Palette.
|
||||
/// </summary>
|
||||
internal sealed partial class GlobalErrorHandler
|
||||
internal sealed partial class GlobalErrorHandler : IDisposable
|
||||
{
|
||||
private readonly ErrorReportBuilder _errorReportBuilder = new();
|
||||
private Options? _options;
|
||||
private App? _app;
|
||||
|
||||
// GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available.
|
||||
internal void Register(App app)
|
||||
internal void Register(App app, Options options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
app.UnhandledException += App_UnhandledException;
|
||||
_options = options;
|
||||
|
||||
_app = app;
|
||||
_app.UnhandledException += App_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
}
|
||||
@@ -54,21 +62,15 @@ internal sealed partial class GlobalErrorHandler
|
||||
HandleException(e.Exception, Context.UnobservedTaskException);
|
||||
}
|
||||
|
||||
private static void HandleException(Exception ex, Context context)
|
||||
private void HandleException(Exception ex, Context context)
|
||||
{
|
||||
Logger.LogError($"Unhandled exception detected ({context})", ex);
|
||||
|
||||
if (context == Context.MainThreadException)
|
||||
{
|
||||
var error = DiagnosticsHelper.BuildExceptionMessage(ex, null);
|
||||
var report = $"""
|
||||
This is an error report generated by Windows Command Palette.
|
||||
If you are seeing this message, it means the application has encountered an unexpected issue.
|
||||
You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
|
||||
{error}
|
||||
""";
|
||||
var report = _errorReportBuilder.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true);
|
||||
|
||||
StoreReport(report, storeOnDesktop: false);
|
||||
StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true);
|
||||
|
||||
string message;
|
||||
string caption;
|
||||
@@ -138,6 +140,13 @@ internal sealed partial class GlobalErrorHandler
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_app?.UnhandledException -= App_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException;
|
||||
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
|
||||
}
|
||||
|
||||
private enum Context
|
||||
{
|
||||
Unknown = 0,
|
||||
@@ -146,4 +155,26 @@ internal sealed partial class GlobalErrorHandler
|
||||
UnobservedTaskException,
|
||||
AppDomainUnhandledException,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options controlling how <see cref="GlobalErrorHandler"/> reacts to exceptions
|
||||
/// (what to log, what to show to the user, and where to store reports).
|
||||
/// </summary>
|
||||
internal sealed record Options
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default configuration.
|
||||
/// </summary>
|
||||
public static Options Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether Personally Identifiable Information (PII) should be redacted in error reports.
|
||||
/// </summary>
|
||||
public bool RedactPii { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to store the error report on the user's desktop in addition to the log directory.
|
||||
/// </summary>
|
||||
public bool StoreReportOnUserDesktop { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ internal sealed partial class TrayIconService
|
||||
{
|
||||
if (wParam == PInvoke.WM_USER + 1)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
}
|
||||
else if (wParam == PInvoke.WM_USER + 2)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Windows.Graphics;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Graphics.Gdi;
|
||||
using Windows.Win32.UI.HiDpi;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal static class WindowPositionHelper
|
||||
{
|
||||
private const int DefaultWidth = 800;
|
||||
private const int DefaultHeight = 480;
|
||||
private const int MinimumVisibleSize = 100;
|
||||
private const int DefaultDpi = 96;
|
||||
|
||||
public static PointInt32? CalculateCenteredPosition(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi)
|
||||
{
|
||||
if (displayArea is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var workArea = displayArea.WorkArea;
|
||||
if (workArea.Width <= 0 || workArea.Height <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetDpi = GetDpiForDisplay(displayArea);
|
||||
var predictedSize = ScaleSize(windowSize, windowDpi, targetDpi);
|
||||
|
||||
// Clamp to work area
|
||||
var width = Math.Min(predictedSize.Width, workArea.Width);
|
||||
var height = Math.Min(predictedSize.Height, workArea.Height);
|
||||
|
||||
return new PointInt32(
|
||||
workArea.X + ((workArea.Width - width) / 2),
|
||||
workArea.Y + ((workArea.Height - height) / 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjusts a saved window rect to ensure it's visible on the nearest display,
|
||||
/// accounting for DPI changes and work area differences.
|
||||
/// </summary>
|
||||
///
|
||||
public static RectInt32 AdjustRectForVisibility(RectInt32 savedRect, SizeInt32 savedScreenSize, int savedDpi)
|
||||
{
|
||||
var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest);
|
||||
if (displayArea is null)
|
||||
{
|
||||
return savedRect;
|
||||
}
|
||||
|
||||
var workArea = displayArea.WorkArea;
|
||||
if (workArea.Width <= 0 || workArea.Height <= 0)
|
||||
{
|
||||
return savedRect;
|
||||
}
|
||||
|
||||
var targetDpi = GetDpiForDisplay(displayArea);
|
||||
if (savedDpi <= 0)
|
||||
{
|
||||
savedDpi = targetDpi;
|
||||
}
|
||||
|
||||
var hasInvalidSize = savedRect.Width <= 0 || savedRect.Height <= 0;
|
||||
if (hasInvalidSize)
|
||||
{
|
||||
savedRect = savedRect with { Width = DefaultWidth, Height = DefaultHeight };
|
||||
}
|
||||
|
||||
if (targetDpi != savedDpi)
|
||||
{
|
||||
savedRect = ScaleRect(savedRect, savedDpi, targetDpi);
|
||||
}
|
||||
|
||||
var clampedSize = ClampSize(savedRect.Width, savedRect.Height, workArea);
|
||||
|
||||
var shouldRecenter = hasInvalidSize ||
|
||||
IsOffscreen(savedRect, workArea) ||
|
||||
savedScreenSize.Width != workArea.Width ||
|
||||
savedScreenSize.Height != workArea.Height;
|
||||
|
||||
if (shouldRecenter)
|
||||
{
|
||||
return CenterRectInWorkArea(clampedSize, workArea);
|
||||
}
|
||||
|
||||
return new RectInt32(savedRect.X, savedRect.Y, clampedSize.Width, clampedSize.Height);
|
||||
}
|
||||
|
||||
private static int GetDpiForDisplay(DisplayArea displayArea)
|
||||
{
|
||||
var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
|
||||
if (hMonitor == IntPtr.Zero)
|
||||
{
|
||||
return DefaultDpi;
|
||||
}
|
||||
|
||||
var hr = PInvoke.GetDpiForMonitor(
|
||||
new HMONITOR(hMonitor),
|
||||
MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI,
|
||||
out var dpiX,
|
||||
out _);
|
||||
|
||||
return hr.Succeeded && dpiX > 0 ? (int)dpiX : DefaultDpi;
|
||||
}
|
||||
|
||||
private static SizeInt32 ScaleSize(SizeInt32 size, int fromDpi, int toDpi)
|
||||
{
|
||||
if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi)
|
||||
{
|
||||
return size;
|
||||
}
|
||||
|
||||
var scale = (double)toDpi / fromDpi;
|
||||
return new SizeInt32(
|
||||
(int)Math.Round(size.Width * scale),
|
||||
(int)Math.Round(size.Height * scale));
|
||||
}
|
||||
|
||||
private static RectInt32 ScaleRect(RectInt32 rect, int fromDpi, int toDpi)
|
||||
{
|
||||
var scale = (double)toDpi / fromDpi;
|
||||
return new RectInt32(
|
||||
(int)Math.Round(rect.X * scale),
|
||||
(int)Math.Round(rect.Y * scale),
|
||||
(int)Math.Round(rect.Width * scale),
|
||||
(int)Math.Round(rect.Height * scale));
|
||||
}
|
||||
|
||||
private static SizeInt32 ClampSize(int width, int height, RectInt32 workArea) =>
|
||||
new(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height));
|
||||
|
||||
private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) =>
|
||||
new(
|
||||
workArea.X + ((workArea.Width - size.Width) / 2),
|
||||
workArea.Y + ((workArea.Height - size.Height) / 2),
|
||||
size.Width,
|
||||
size.Height);
|
||||
|
||||
private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea) =>
|
||||
rect.X + MinimumVisibleSize > workArea.X + workArea.Width ||
|
||||
rect.X + rect.Width - MinimumVisibleSize < workArea.X ||
|
||||
rect.Y + MinimumVisibleSize > workArea.Y + workArea.Height ||
|
||||
rect.Y + rect.Height - MinimumVisibleSize < workArea.Y;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Composition;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI.Input;
|
||||
@@ -32,13 +31,9 @@ using Windows.ApplicationModel.Activation;
|
||||
using Windows.Foundation;
|
||||
using Windows.Graphics;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
using Windows.UI.WindowManagement;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.Graphics.Dwm;
|
||||
using Windows.Win32.Graphics.Gdi;
|
||||
using Windows.Win32.UI.HiDpi;
|
||||
using Windows.Win32.UI.Input.KeyboardAndMouse;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
using WinRT;
|
||||
@@ -60,9 +55,6 @@ public sealed partial class MainWindow : WindowEx,
|
||||
IRecipient<DragCompletedMessage>,
|
||||
IDisposable
|
||||
{
|
||||
private const int DefaultWidth = 800;
|
||||
private const int DefaultHeight = 480;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")]
|
||||
private readonly uint WM_TASKBAR_RESTART;
|
||||
@@ -226,39 +218,40 @@ public sealed partial class MainWindow : WindowEx,
|
||||
PositionCentered(displayArea);
|
||||
}
|
||||
|
||||
private void PositionCentered(DisplayArea displayArea)
|
||||
{
|
||||
var position = WindowPositionHelper.CalculateCenteredPosition(
|
||||
displayArea,
|
||||
AppWindow.Size,
|
||||
(int)this.GetDpiForWindow());
|
||||
|
||||
if (position is not null)
|
||||
{
|
||||
// Use Move(), not MoveAndResize(). Windows auto-resizes on DPI change via WM_DPICHANGED;
|
||||
// the helper already accounts for this when calculating the centered position.
|
||||
AppWindow.Move((PointInt32)position);
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreWindowPosition()
|
||||
{
|
||||
var settings = App.Current.Services.GetService<SettingsModel>();
|
||||
if (settings?.LastWindowPosition is not WindowPosition savedPosition)
|
||||
if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition)
|
||||
{
|
||||
PositionCentered();
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedPosition.Width <= 0 || savedPosition.Height <= 0)
|
||||
{
|
||||
PositionCentered();
|
||||
return;
|
||||
}
|
||||
// MoveAndResize is safe here—we're restoring a saved state at startup,
|
||||
// not moving a live window between displays.
|
||||
var newRect = WindowPositionHelper.AdjustRectForVisibility(
|
||||
savedPosition.ToPhysicalWindowRectangle(),
|
||||
new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight),
|
||||
savedPosition.Dpi);
|
||||
|
||||
var newRect = EnsureWindowIsVisible(savedPosition.ToPhysicalWindowRectangle(), new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), savedPosition.Dpi);
|
||||
AppWindow.MoveAndResize(newRect);
|
||||
}
|
||||
|
||||
private void PositionCentered(DisplayArea displayArea)
|
||||
{
|
||||
if (displayArea is not null)
|
||||
{
|
||||
var centeredPosition = AppWindow.Position;
|
||||
centeredPosition.X = (displayArea.WorkArea.Width - AppWindow.Size.Width) / 2;
|
||||
centeredPosition.Y = (displayArea.WorkArea.Height - AppWindow.Size.Height) / 2;
|
||||
|
||||
centeredPosition.X += displayArea.WorkArea.X;
|
||||
centeredPosition.Y += displayArea.WorkArea.Y;
|
||||
AppWindow.Move(centeredPosition);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateWindowPositionInMemory()
|
||||
{
|
||||
var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary;
|
||||
@@ -352,7 +345,8 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
if (target == MonitorBehavior.ToLast)
|
||||
{
|
||||
var newRect = EnsureWindowIsVisible(_currentWindowPosition.ToPhysicalWindowRectangle(), new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight), _currentWindowPosition.Dpi);
|
||||
var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight);
|
||||
var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi);
|
||||
AppWindow.MoveAndResize(newRect);
|
||||
}
|
||||
else
|
||||
@@ -382,115 +376,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the window rectangle is visible on-screen.
|
||||
/// </summary>
|
||||
/// <param name="windowRect">The window rectangle in physical pixels.</param>
|
||||
/// <param name="originalScreen">The desktop area the window was positioned on.</param>
|
||||
/// <param name="originalDpi">The window's original DPI.</param>
|
||||
/// <returns>
|
||||
/// A window rectangle in physical pixels, moved to the nearest display and resized
|
||||
/// if the DPI has changed.
|
||||
/// </returns>
|
||||
private static RectInt32 EnsureWindowIsVisible(RectInt32 windowRect, SizeInt32 originalScreen, int originalDpi)
|
||||
{
|
||||
var displayArea = DisplayArea.GetFromRect(windowRect, DisplayAreaFallback.Nearest);
|
||||
if (displayArea is null)
|
||||
{
|
||||
return windowRect;
|
||||
}
|
||||
|
||||
var workArea = displayArea.WorkArea;
|
||||
if (workArea.Width <= 0 || workArea.Height <= 0)
|
||||
{
|
||||
// Fallback, nothing reasonable to do
|
||||
return windowRect;
|
||||
}
|
||||
|
||||
var effectiveDpi = GetEffectiveDpiFromDisplayId(displayArea);
|
||||
if (originalDpi <= 0)
|
||||
{
|
||||
originalDpi = effectiveDpi; // use current DPI as baseline (no scaling adjustment needed)
|
||||
}
|
||||
|
||||
var hasInvalidSize = windowRect.Width <= 0 || windowRect.Height <= 0;
|
||||
if (hasInvalidSize)
|
||||
{
|
||||
windowRect = new RectInt32(windowRect.X, windowRect.Y, DefaultWidth, DefaultHeight);
|
||||
}
|
||||
|
||||
// If we have a DPI change, scale the window rectangle accordingly
|
||||
if (effectiveDpi != originalDpi)
|
||||
{
|
||||
var scalingFactor = effectiveDpi / (double)originalDpi;
|
||||
windowRect = new RectInt32(
|
||||
(int)Math.Round(windowRect.X * scalingFactor),
|
||||
(int)Math.Round(windowRect.Y * scalingFactor),
|
||||
(int)Math.Round(windowRect.Width * scalingFactor),
|
||||
(int)Math.Round(windowRect.Height * scalingFactor));
|
||||
}
|
||||
|
||||
var targetWidth = Math.Min(windowRect.Width, workArea.Width);
|
||||
var targetHeight = Math.Min(windowRect.Height, workArea.Height);
|
||||
|
||||
// Ensure at least some minimum visible area (e.g., 100 pixels)
|
||||
// This helps prevent the window from being entirely offscreen, regardless of display scaling.
|
||||
const int minimumVisibleSize = 100;
|
||||
var isOffscreen =
|
||||
windowRect.X + minimumVisibleSize > workArea.X + workArea.Width ||
|
||||
windowRect.X + windowRect.Width - minimumVisibleSize < workArea.X ||
|
||||
windowRect.Y + minimumVisibleSize > workArea.Y + workArea.Height ||
|
||||
windowRect.Y + windowRect.Height - minimumVisibleSize < workArea.Y;
|
||||
|
||||
// if the work area size has changed, re-center the window
|
||||
var workAreaSizeChanged =
|
||||
originalScreen.Width != workArea.Width ||
|
||||
originalScreen.Height != workArea.Height;
|
||||
|
||||
int targetX;
|
||||
int targetY;
|
||||
var recenter = isOffscreen || workAreaSizeChanged || hasInvalidSize;
|
||||
if (recenter)
|
||||
{
|
||||
targetX = workArea.X + ((workArea.Width - targetWidth) / 2);
|
||||
targetY = workArea.Y + ((workArea.Height - targetHeight) / 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetX = windowRect.X;
|
||||
targetY = windowRect.Y;
|
||||
}
|
||||
|
||||
return new RectInt32(targetX, targetY, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
private static int GetEffectiveDpiFromDisplayId(DisplayArea displayArea)
|
||||
{
|
||||
var effectiveDpi = 96;
|
||||
|
||||
var hMonitor = (HMONITOR)Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
|
||||
if (!hMonitor.IsNull)
|
||||
{
|
||||
var hr = PInvoke.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var dpiX, out _);
|
||||
if (hr == 0)
|
||||
{
|
||||
effectiveDpi = (int)dpiX;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning($"GetDpiForMonitor failed with HRESULT: 0x{hr.Value:X8} on display {displayArea.DisplayId}");
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveDpi <= 0)
|
||||
{
|
||||
effectiveDpi = 96;
|
||||
}
|
||||
|
||||
return effectiveDpi;
|
||||
}
|
||||
|
||||
private DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
|
||||
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
|
||||
{
|
||||
// Leaving a note here, in case we ever need it:
|
||||
// https://github.com/microsoft/microsoft-ui-xaml/issues/6454
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<None Remove="Pages\Settings\GeneralPage.xaml" />
|
||||
<None Remove="SettingsWindow.xaml" />
|
||||
<None Remove="Settings\AppearancePage.xaml" />
|
||||
<None Remove="Settings\InternalPage.xaml" />
|
||||
<None Remove="ShellPage.xaml" />
|
||||
<None Remove="Styles\Colors.xaml" />
|
||||
<None Remove="Styles\Settings.xaml" />
|
||||
@@ -264,6 +265,11 @@
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Settings\InternalPage.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Styles\Colors.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
|
||||
@@ -257,11 +257,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
{
|
||||
_ = DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
OpenSettings();
|
||||
OpenSettings(message.SettingsPageTag);
|
||||
});
|
||||
}
|
||||
|
||||
public void OpenSettings()
|
||||
public void OpenSettings(string pageTag)
|
||||
{
|
||||
if (_settingsWindow is null)
|
||||
{
|
||||
@@ -270,6 +270,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
_settingsWindow.Activate();
|
||||
_settingsWindow.BringToFront();
|
||||
_settingsWindow.Navigate(pageTag);
|
||||
}
|
||||
|
||||
public void Receive(ShowDetailsMessage message)
|
||||
|
||||
@@ -229,12 +229,26 @@
|
||||
<controls:SettingsCard.HeaderIcon>
|
||||
<cpcontrols:ContentIcon>
|
||||
<cpcontrols:ContentIcon.Content>
|
||||
<cpcontrols:IconBox
|
||||
Width="20"
|
||||
Height="20"
|
||||
<controls:SwitchPresenter
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
TargetType="x:Boolean"
|
||||
Value="{x:Bind Icon.IsSet, FallbackValue=x:False, Mode=OneWay}">
|
||||
<controls:Case Value="True">
|
||||
<cpcontrols:IconBox
|
||||
Width="20"
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
</controls:Case>
|
||||
<controls:Case Value="False">
|
||||
<Image
|
||||
Width="20"
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Source="ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png" />
|
||||
</controls:Case>
|
||||
</controls:SwitchPresenter>
|
||||
</cpcontrols:ContentIcon.Content>
|
||||
</cpcontrols:ContentIcon>
|
||||
</controls:SettingsCard.HeaderIcon>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Settings;
|
||||
|
||||
public partial class InternalPage
|
||||
{
|
||||
internal static class SampleData
|
||||
{
|
||||
internal static string ExceptionMessageWithPii { get; } =
|
||||
$"""
|
||||
Test exception with personal information; thrown from the UI thread
|
||||
|
||||
Here is e-mail address <jane.doe@contoso.com>
|
||||
IPv4 address: 192.168.100.1
|
||||
IPv4 loopback address: 127.0.0.1
|
||||
MAC address: 00-14-22-01-23-45
|
||||
IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
|
||||
IPv6 loopback address: ::1
|
||||
Password: P@ssw0rd123!
|
||||
Password=secret
|
||||
Api key: 1234567890abcdef
|
||||
PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb
|
||||
InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;
|
||||
X-API-key: 1234567890abcdef
|
||||
Pet-Shop-Subscription-Key: 1234567890abcdef
|
||||
Here is a user name {Environment.UserName}
|
||||
And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\Pictures
|
||||
Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal
|
||||
Here is machine name {Environment.MachineName}
|
||||
JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
|
||||
User email john.doe@company.com failed validation
|
||||
File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt
|
||||
Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test
|
||||
Phone number 555-123-4567 is invalid
|
||||
API key abc123def456ghi789jkl012mno345pqr678 expired
|
||||
Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123
|
||||
Error accessing file://C:/Users/john.doe/Documents/confidential.pdf
|
||||
JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret
|
||||
FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv
|
||||
Email service error: mailto:admin@internal-company.com?subject=Alert
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Page
|
||||
x:Class="Microsoft.CmdPal.UI.Settings.InternalPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<Grid Padding="16">
|
||||
<StackPanel
|
||||
MaxWidth="1000"
|
||||
HorizontalAlignment="Stretch"
|
||||
Spacing="{StaticResource SettingsCardSpacing}">
|
||||
|
||||
<TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="Tools on this page are for internal use only. This page is not visible in CI builds." />
|
||||
|
||||
<!-- Exception Handling Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Exception Handling" />
|
||||
<controls:SettingsExpander
|
||||
Description="Actions for testing global exception handling from the application"
|
||||
Header="Throw exceptions"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<controls:SettingsExpander.Items>
|
||||
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread">
|
||||
<Button Click="ThrowPlainMainThreadException_Click" Content="Throw" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread (with PII)">
|
||||
<Button Click="ThrowPlainMainThreadExceptionPii_Click" Content="Throw" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard Description="Throw with delay, when the task is collected by the GC" Header="Throw unobserved exception from a task">
|
||||
<Button Click="ThrowExceptionInUnobservedTask_Click" Content="Throw" />
|
||||
</controls:SettingsCard>
|
||||
</controls:SettingsExpander.Items>
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<!-- Diagnostics Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Diagnostics" />
|
||||
<controls:SettingsCard
|
||||
x:Name="LogsSettingsCard"
|
||||
Header="Logs folder"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<Button Click="OpenLogsCardClicked" Content="Open folder" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard
|
||||
x:Name="CurrentLogFileSettingsCard"
|
||||
Header="Current log file"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<Button Click="OpenCurrentLogCardClicked" Content="Open log" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<!-- Data Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Data and Files" />
|
||||
<controls:SettingsCard
|
||||
x:Name="ConfigurationFolderSettingsCard"
|
||||
Header="Configuration folder"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<Button Click="OpenConfigFolderCardClick" Content="Open folder" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Page>
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Windows.System;
|
||||
using Page = Microsoft.UI.Xaml.Controls.Page;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// An empty page that can be used on its own or navigated to within a Frame.
|
||||
/// </summary>
|
||||
public sealed partial class InternalPage : Page
|
||||
{
|
||||
public InternalPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void ThrowPlainMainThreadException_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Throwing test exception from the UI thread");
|
||||
throw new NotImplementedException("Test exception; thrown from the UI thread");
|
||||
}
|
||||
|
||||
private void ThrowExceptionInUnobservedTask_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Starting a task that will throw test exception");
|
||||
Task.Run(() =>
|
||||
{
|
||||
Logger.LogDebug("Throwing test exception from a task");
|
||||
throw new InvalidOperationException("Test exception; thrown from a task");
|
||||
});
|
||||
}
|
||||
|
||||
private void ThrowPlainMainThreadExceptionPii_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Throwing test exception from the UI thread (PII)");
|
||||
throw new InvalidOperationException(SampleData.ExceptionMessageWithPii);
|
||||
}
|
||||
|
||||
private async void OpenLogsCardClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFolderPath = Logger.CurrentVersionLogDirectoryPath;
|
||||
if (Directory.Exists(logFolderPath))
|
||||
{
|
||||
await Launcher.LaunchFolderPathAsync(logFolderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to open directory in Explorer", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenCurrentLogCardClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logPath = Logger.CurrentLogFile;
|
||||
if (File.Exists(logPath))
|
||||
{
|
||||
await Launcher.LaunchUriAsync(new Uri(logPath));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to open log file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenConfigFolderCardClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
await Launcher.LaunchFolderPathAsync(directory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to open directory in Explorer", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@
|
||||
x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions"
|
||||
Icon="{ui:FontIcon Glyph=}"
|
||||
Tag="Extensions" />
|
||||
<!-- "Internal Tools" page item is added dynamically from code -->
|
||||
</NavigationView.MenuItems>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
|
||||
@@ -30,6 +30,8 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
{
|
||||
private readonly LocalKeyboardListener _localKeyboardListener;
|
||||
|
||||
private readonly NavigationViewItem? _internalNavItem;
|
||||
|
||||
public ObservableCollection<Crumb> BreadCrumbs { get; } = [];
|
||||
|
||||
// Gets or sets optional action invoked after NavigationView is loaded.
|
||||
@@ -54,6 +56,23 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
_localKeyboardListener.Start();
|
||||
Closed += SettingsWindow_Closed;
|
||||
RootElement.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(RootElement_OnPointerPressed), true);
|
||||
|
||||
if (!BuildInfo.IsCiBuild)
|
||||
{
|
||||
_internalNavItem = new NavigationViewItem
|
||||
{
|
||||
Content = "Internal Tools",
|
||||
Icon = new FontIcon { Glyph = "\uEC7A" },
|
||||
Tag = "Internal",
|
||||
};
|
||||
NavView.MenuItems.Add(_internalNavItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
_internalNavItem = null;
|
||||
}
|
||||
|
||||
Navigate("General");
|
||||
}
|
||||
|
||||
private void SettingsWindow_Closed(object sender, WindowEventArgs args)
|
||||
@@ -68,9 +87,6 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
// Delay necessary to ensure NavigationView visual state can match navigation
|
||||
Task.Delay(500).ContinueWith(_ => this.NavigationViewLoaded?.Invoke(), TaskScheduler.FromCurrentSynchronizationContext());
|
||||
|
||||
NavView.SelectedItem = NavView.MenuItems[0];
|
||||
Navigate("General");
|
||||
|
||||
if (sender is NavigationView navigationView)
|
||||
{
|
||||
// Register for pane open/close changes to announce to screen readers
|
||||
@@ -96,15 +112,33 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
Navigate((selectedItem.Tag as string)!);
|
||||
}
|
||||
|
||||
private void Navigate(string page)
|
||||
internal void Navigate(string page)
|
||||
{
|
||||
var pageType = page switch
|
||||
Type? pageType;
|
||||
switch (page)
|
||||
{
|
||||
"General" => typeof(GeneralPage),
|
||||
"Appearance" => typeof(AppearancePage),
|
||||
"Extensions" => typeof(ExtensionsPage),
|
||||
_ => null,
|
||||
};
|
||||
case "General":
|
||||
pageType = typeof(GeneralPage);
|
||||
break;
|
||||
case "Appearance":
|
||||
pageType = typeof(AppearancePage);
|
||||
break;
|
||||
case "Extensions":
|
||||
pageType = typeof(ExtensionsPage);
|
||||
break;
|
||||
case "Internal":
|
||||
pageType = typeof(InternalPage);
|
||||
break;
|
||||
case "":
|
||||
// intentional no-op: empty tag means no navigation
|
||||
pageType = null;
|
||||
break;
|
||||
default:
|
||||
// unknown page, no-op and log
|
||||
pageType = null;
|
||||
Logger.LogError($"Unknown settings page tag '{page}'");
|
||||
break;
|
||||
}
|
||||
|
||||
if (pageType is not null)
|
||||
{
|
||||
@@ -268,6 +302,12 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
BreadCrumbs.Add(new(extensionsPageType, extensionsPageType));
|
||||
BreadCrumbs.Add(new(vm.DisplayName, vm));
|
||||
}
|
||||
else if (e.SourcePageType == typeof(InternalPage) && _internalNavItem is not null)
|
||||
{
|
||||
NavView.SelectedItem = _internalNavItem;
|
||||
var pageType = "Internal";
|
||||
BreadCrumbs.Add(new(pageType, pageType));
|
||||
}
|
||||
else
|
||||
{
|
||||
BreadCrumbs.Add(new($"[{e.SourcePageType?.Name}]", string.Empty));
|
||||
|
||||
@@ -8,8 +8,10 @@ using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.UI.Helpers;
|
||||
using Microsoft.CmdPal.UI.Messages;
|
||||
using Microsoft.UI;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
@@ -99,6 +101,12 @@ internal sealed partial class DevRibbonViewModel : ObservableObject
|
||||
LatestLogs.Clear();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenInternalTools()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Internal"));
|
||||
}
|
||||
|
||||
private sealed partial class DevRibbonTraceListener(DevRibbonViewModel viewModel) : TraceListener
|
||||
{
|
||||
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Diagnostics.CodeAnalysis;
|
||||
global using System.Linq;
|
||||
global using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Microsoft.CmdPal.Common.UnitTests</RootNamespace>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public class ConnectionStringRuleProviderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetRules_ShouldReturnExpectedRules()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var rules = provider.GetRules();
|
||||
|
||||
// Assert
|
||||
var ruleList = new List<SanitizationRule>(rules);
|
||||
Assert.AreEqual(1, ruleList.Count);
|
||||
Assert.AreEqual("Connection string parameters", ruleList[0].Description);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Server=localhost;Database=mydb;User ID=admin;Password=secret123", "Server=[REDACTED];Database=[REDACTED];User ID=[REDACTED];Password=[REDACTED]")]
|
||||
[DataRow("Data Source=server.example.com;Initial Catalog=testdb;Uid=user;Pwd=pass", "Data Source=[REDACTED];Initial Catalog=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED]")]
|
||||
[DataRow("Server=localhost;Password=my_secret", "Server=[REDACTED];Password=[REDACTED]")]
|
||||
[DataRow("No connection string here", "No connection string here")]
|
||||
public void ConnectionStringRules_ShouldMaskConnectionStringParameters(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Password=\"complexPassword123!\"", "Password=[REDACTED]")]
|
||||
[DataRow("Password='myPassword'", "Password=[REDACTED]")]
|
||||
[DataRow("Password=unquotedSecret", "Password=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldHandleQuotedAndUnquotedValues(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("SERVER=server1;PASSWORD=pass1", "SERVER=[REDACTED];PASSWORD=[REDACTED]")]
|
||||
[DataRow("server=server1;password=pass1", "server=[REDACTED];password=[REDACTED]")]
|
||||
[DataRow("Server=server1;Password=pass1", "Server=[REDACTED];Password=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldBeCaseInsensitive(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("User ID=admin;Username=john;Password=secret", "User ID=[REDACTED];Username=[REDACTED];Password=[REDACTED]")]
|
||||
[DataRow("Database=mydb;Uid=user1;Pwd=pass1;Server=localhost", "Database=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED];Server=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldHandleMultipleParameters(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Server = localhost ; Password = secret123", "Server=[REDACTED] ; Password=[REDACTED]")]
|
||||
[DataRow("Initial Catalog=db; User ID=admin; Password=pass", "Initial Catalog=[REDACTED]; User ID=[REDACTED]; Password=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldHandleWhitespace(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
public partial class ErrorReportSanitizerTests
|
||||
{
|
||||
private static class TestData
|
||||
{
|
||||
internal static string Input =>
|
||||
$"""
|
||||
HRESULT: 0x80004005
|
||||
HRESULT: -2147467259
|
||||
|
||||
Here is e-mail address <jane.doe@contoso.com>
|
||||
IPv4 address: 192.168.100.1
|
||||
IPv4 loopback address: 127.0.0.1
|
||||
MAC address: 00-14-22-01-23-45
|
||||
IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
|
||||
IPv6 loopback address: ::1
|
||||
Password: P@ssw0rd123!
|
||||
Password=secret
|
||||
Api key: 1234567890abcdef
|
||||
PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb
|
||||
InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;
|
||||
X-API-key: 1234567890abcdef
|
||||
Pet-Shop-Subscription-Key: 1234567890abcdef
|
||||
Here is a user name {Environment.UserName}
|
||||
And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\RandomFolder
|
||||
Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal
|
||||
Here is machine name {Environment.MachineName}
|
||||
JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
|
||||
User email john.doe@company.com failed validation
|
||||
File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt
|
||||
Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test
|
||||
Phone number 555-123-4567 is invalid
|
||||
API key abc123def456ghi789jkl012mno345pqr678 expired
|
||||
Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123
|
||||
Error accessing file://C:/Users/john.doe/Documents/confidential.pdf
|
||||
JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret
|
||||
FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv
|
||||
Email service error: mailto:admin@internal-company.com?subject=Alert
|
||||
""";
|
||||
|
||||
public const string Expected =
|
||||
$"""
|
||||
HRESULT: 0x80004005
|
||||
HRESULT: -2147467259
|
||||
|
||||
Here is e-mail address <[EMAIL_REDACTED]>
|
||||
IPv4 address: [IP4_REDACTED]
|
||||
IPv4 loopback address: [IP4_REDACTED]
|
||||
MAC address: [MAC_ADDRESS_REDACTED]
|
||||
IPv6 address: [IP6_REDACTED]
|
||||
IPv6 loopback address: [IP6_REDACTED]
|
||||
Password: [REDACTED]
|
||||
Password= [REDACTED]
|
||||
Api key: [REDACTED]
|
||||
PostgreSQL connection string: [REDACTED]
|
||||
InstrumentationKey= [REDACTED]
|
||||
X-API-key: [REDACTED]
|
||||
Pet-Shop-Subscription-Key: [REDACTED]
|
||||
Here is a user name [USERNAME_REDACTED]
|
||||
And here is a profile path [USER_PROFILE_DIR]RandomFolder
|
||||
Here is a local app data path [LOCALAPPLICATIONDATA_DIR]Microsoft\PowerToys\CmdPal
|
||||
Here is machine name [MACHINE_NAME_REDACTED]
|
||||
JWT token: [REDACTED]
|
||||
User email [EMAIL_REDACTED] failed validation
|
||||
File not found: [MYDOCUMENTS_DIR]se****.txt
|
||||
Connection string: [REDACTED] ID=[REDACTED];Password= [REDACTED]
|
||||
Phone number [PHONE_REDACTED] is invalid
|
||||
API key [TOKEN_REDACTED] expired
|
||||
Failed to connect to [URL_REDACTED]
|
||||
Error accessing [URL_REDACTED]
|
||||
JDBC connection failed: [URL_REDACTED]
|
||||
FTP upload error: [URL_REDACTED]
|
||||
Email service error: mailto:[EMAIL_REDACTED]?subject=Alert
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public partial class ErrorReportSanitizerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Sanitize_ShouldMaskPiiInErrorReport()
|
||||
{
|
||||
// Arrange
|
||||
var reportSanitizer = new ErrorReportSanitizer();
|
||||
var input = TestData.Input;
|
||||
|
||||
// Act
|
||||
var result = reportSanitizer.Sanitize(input);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(TestData.Expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public class PiiRuleProviderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetRules_ShouldReturnExpectedRules()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var rules = provider.GetRules();
|
||||
|
||||
// Assert
|
||||
var ruleList = new List<SanitizationRule>(rules);
|
||||
Assert.AreEqual(4, ruleList.Count);
|
||||
Assert.AreEqual("Email addresses", ruleList[0].Description);
|
||||
Assert.AreEqual("Social Security Numbers", ruleList[1].Description);
|
||||
Assert.AreEqual("Credit card numbers", ruleList[2].Description);
|
||||
Assert.AreEqual("Phone numbers", ruleList[3].Description);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Contact me at john.doe@contoso.com", "Contact me at [EMAIL_REDACTED]")]
|
||||
[DataRow("Contact me at a_b-c%2@foo-bar.example.co.uk", "Contact me at [EMAIL_REDACTED]")]
|
||||
[DataRow("My email is john@sub-domain.contoso.com.", "My email is [EMAIL_REDACTED].")]
|
||||
[DataRow("Two: a@b.com and c@d.org", "Two: [EMAIL_REDACTED] and [EMAIL_REDACTED]")]
|
||||
[DataRow("No email here", "No email here")]
|
||||
public void EmailRules_ShouldMaskEmailAddresses(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Call me at 123-456-7890", "Call me at [PHONE_REDACTED]")]
|
||||
[DataRow("My number is (123) 456-7890.", "My number is [PHONE_REDACTED].")]
|
||||
[DataRow("Office: +1 123 456 7890", "Office: [PHONE_REDACTED]")]
|
||||
[DataRow("Two numbers: 123-456-7890 and +420 777123456", "Two numbers: [PHONE_REDACTED] and [PHONE_REDACTED]")]
|
||||
[DataRow("Czech phone +420 777 123 456", "Czech phone [PHONE_REDACTED]")]
|
||||
[DataRow("Slovak phone +421 777 12 34 56", "Slovak phone [PHONE_REDACTED]")]
|
||||
[DataRow("No phone number here", "No phone number here")]
|
||||
public void PhoneRules_ShouldMaskPhoneNumbers(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("My SSN is 123-45-6789", "My SSN is [SSN_REDACTED]")]
|
||||
[DataRow("No SSN here", "No SSN here")]
|
||||
public void SsnRules_ShouldMaskSsn(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("My credit card number is 1234-5678-9012-3456", "My credit card number is [CARD_REDACTED]")]
|
||||
[DataRow("My credit card number is 1234567890123456", "My credit card number is [CARD_REDACTED]")]
|
||||
[DataRow("No credit card here", "No credit card here")]
|
||||
public void CreditCardRules_ShouldMaskCreditCardNumbers(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Error code: 0x80070005", "Error code: 0x80070005")]
|
||||
[DataRow("Error code: -2147467262", "Error code: -2147467262")]
|
||||
[DataRow("GUID: 123e4567-e89b-12d3-a456-426614174000", "GUID: 123e4567-e89b-12d3-a456-426614174000")]
|
||||
[DataRow("Timestamp: 2023-10-05T14:32:10Z", "Timestamp: 2023-10-05T14:32:10Z")]
|
||||
[DataRow("Version: 1.2.3", "Version: 1.2.3")]
|
||||
[DataRow("Version: 10.0.22631.3448", "Version: 10.0.22631.3448")]
|
||||
[DataRow("MAC: 00:1A:2B:3C:4D:5E", "MAC: 00:1A:2B:3C:4D:5E")]
|
||||
[DataRow("Date: 2023-10-05", "Date: 2023-10-05")]
|
||||
[DataRow("Date: 05/10/2023", "Date: 05/10/2023")]
|
||||
public void PiiRuleProvider_ShouldNotOverRedact(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public class SecretKeyValueRulesProviderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetRules_ShouldReturnExpectedRules()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var rules = provider.GetRules();
|
||||
|
||||
// Assert
|
||||
var ruleList = new List<SanitizationRule>(rules);
|
||||
Assert.AreEqual(1, ruleList.Count);
|
||||
Assert.AreEqual("Sensitive key/value pairs", ruleList[0].Description);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password=secret123", "password= [REDACTED]")]
|
||||
[DataRow("passphrase=myPassphrase", "passphrase= [REDACTED]")]
|
||||
[DataRow("pwd=test", "pwd= [REDACTED]")]
|
||||
[DataRow("passwd=pass1234", "passwd= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskPasswordSecrets(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("token=abc123def456", "token= [REDACTED]")]
|
||||
[DataRow("access_token=token_value", "access_token= [REDACTED]")]
|
||||
[DataRow("refresh-token=refresh_value", "refresh-token= [REDACTED]")]
|
||||
[DataRow("id token=id_token_value", "id token= [REDACTED]")]
|
||||
[DataRow("bearer token=bearer_value", "bearer token= [REDACTED]")]
|
||||
[DataRow("session token=session_value", "session token= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskTokens(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("api key=my_api_key", "api key= [REDACTED]")]
|
||||
[DataRow("api-key=key123", "api-key= [REDACTED]")]
|
||||
[DataRow("api_key=secret_key", "api_key= [REDACTED]")]
|
||||
[DataRow("x-api-key=api123", "x-api-key= [REDACTED]")]
|
||||
[DataRow("x api key=key456", "x api key= [REDACTED]")]
|
||||
[DataRow("client id=client123", "client id= [REDACTED]")]
|
||||
[DataRow("client-secret=secret123", "client-secret= [REDACTED]")]
|
||||
[DataRow("consumer secret=secret456", "consumer secret= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskApiCredentials(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("subscription key=sub_key_123", "subscription key= [REDACTED]")]
|
||||
[DataRow("instrumentation key=instr_key", "instrumentation key= [REDACTED]")]
|
||||
[DataRow("account key=account123", "account key= [REDACTED]")]
|
||||
[DataRow("storage account key=storage_key", "storage account key= [REDACTED]")]
|
||||
[DataRow("shared access key=sak123", "shared access key= [REDACTED]")]
|
||||
[DataRow("SAS token=sas123", "SAS token= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskCloudPlatformKeys(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("connection string=Server=localhost;Pwd=pass", "connection string= [REDACTED]")]
|
||||
[DataRow("conn string=conn_value", "conn string= [REDACTED]")]
|
||||
[DataRow("storage connection string=connection_value", "storage connection string= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskConnectionStrings(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("private key=pk123", "private key= [REDACTED]")]
|
||||
[DataRow("certificate password=cert_pass", "certificate password= [REDACTED]")]
|
||||
[DataRow("client certificate password=cert123", "client certificate password= [REDACTED]")]
|
||||
[DataRow("pfx password=pfx_pass", "pfx password= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskCertificateSecrets(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("aws access key id=AKIAIOSFODNN7EXAMPLE", "aws access key id= [REDACTED]")]
|
||||
[DataRow("aws secret access key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "aws secret access key= [REDACTED]")]
|
||||
[DataRow("aws session token=session_token_value", "aws session token= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskAwsKeys(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password=\"complexPassword123!\"", "password= \"[REDACTED]\"")]
|
||||
[DataRow("api-key='secret-key'", "api-key= '[REDACTED]'")]
|
||||
[DataRow("token=\"bearer_token_value\"", "token= \"[REDACTED]\"")]
|
||||
public void SecretKeyValueRules_ShouldPreserveQuotesAroundRedactedValue(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("PASSWORD=secret", "PASSWORD= [REDACTED]")]
|
||||
[DataRow("Api-Key=key123", "Api-Key= [REDACTED]")]
|
||||
[DataRow("CLIENT_ID=client123", "CLIENT_ID= [REDACTED]")]
|
||||
[DataRow("Pwd=pass123", "Pwd= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldBeCaseInsensitive(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("regularKey=regularValue", "regularKey=regularValue")]
|
||||
[DataRow("config=myConfig", "config=myConfig")]
|
||||
[DataRow("hostname=server.example.com", "hostname=server.example.com")]
|
||||
[DataRow("port=8080", "port=8080")]
|
||||
public void SecretKeyValueRules_ShouldNotRedactNonSecretKeyValuePairs(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password:secret123", "password: [REDACTED]")]
|
||||
[DataRow("api key:api_key_value", "api key: [REDACTED]")]
|
||||
[DataRow("client_secret:secret_value", "client_secret: [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldSupportColonSeparator(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password = secret123", "password= [REDACTED]")]
|
||||
[DataRow("api key = api_key_value", "api key= [REDACTED]")]
|
||||
[DataRow("token : token_value", "token: [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldHandleWhitespace(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password=secret API_KEY=key config=myConfig", "password= [REDACTED] API_KEY= [REDACTED] config=myConfig")]
|
||||
[DataRow("client_id=id123 name=admin pwd=pass123", "client_id= [REDACTED] name=admin pwd= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldHandleMultipleKeyValuePairsInSingleString(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("cosmos db key=cosmos_key", "cosmos db key= [REDACTED]")]
|
||||
[DataRow("service principal secret=sp_secret", "service principal secret= [REDACTED]")]
|
||||
[DataRow("shared access signature=sas_signature", "shared access signature= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskServiceSpecificSecrets(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only helpers for applying SanitizationRule sets without relying on production ITextSanitizer implementation.
|
||||
/// </summary>
|
||||
public static class SanitizerTestHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies the provided rules to the input, in order, mimicking the production sanitizer behavior closely
|
||||
/// but without any external dependencies.
|
||||
/// </summary>
|
||||
public static string ApplyRules(string? input, IEnumerable<SanitizationRule> rules)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = input;
|
||||
foreach (var rule in rules ?? [])
|
||||
{
|
||||
try
|
||||
{
|
||||
var previous = result;
|
||||
result = rule.Evaluator is null
|
||||
? rule.Regex.Replace(previous, rule.Replacement ?? string.Empty)
|
||||
: rule.Regex.Replace(previous, rule.Evaluator);
|
||||
|
||||
// Guardrail to avoid accidental mass-redaction from a faulty rule
|
||||
if (result.Length < previous.Length * 0.3)
|
||||
{
|
||||
result = previous;
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
// Ignore timeouts in tests
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a lightweight sanitizer instance backed by the given rules.
|
||||
/// Useful when a component expects an ITextSanitizer, but you want deterministic behavior in tests.
|
||||
/// </summary>
|
||||
public static ITextSanitizer CreateSanitizer(IEnumerable<SanitizationRule> rules)
|
||||
=> new InlineSanitizer(rules);
|
||||
|
||||
private sealed class InlineSanitizer : ITextSanitizer
|
||||
{
|
||||
private readonly List<SanitizationRule> _rules;
|
||||
|
||||
public InlineSanitizer(IEnumerable<SanitizationRule> rules)
|
||||
{
|
||||
_rules = rules?.ToList() ?? [];
|
||||
}
|
||||
|
||||
public string Sanitize(string? input) => ApplyRules(input, _rules);
|
||||
|
||||
public void AddRule(string pattern, string replacement, string description = "")
|
||||
{
|
||||
var rx = new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
_rules.Add(new SanitizationRule(rx, replacement, description));
|
||||
}
|
||||
|
||||
public void RemoveRule(string description)
|
||||
{
|
||||
_rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public IReadOnlyList<SanitizationRule> GetRules() => _rules.AsReadOnly();
|
||||
|
||||
public string TestRule(string input, string ruleDescription)
|
||||
{
|
||||
var rule = _rules.FirstOrDefault(r => r.Description.Contains(ruleDescription, StringComparison.OrdinalIgnoreCase));
|
||||
if (rule.Regex is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (rule.Evaluator is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Evaluator);
|
||||
}
|
||||
|
||||
if (rule.Replacement is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Replacement);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore exceptions for test determinism
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class CloseOnEnterTests
|
||||
{
|
||||
var settings = new Settings(closeOnEnter: true);
|
||||
TypedEventHandler<object, object> handleSave = (s, e) => { };
|
||||
TypedEventHandler<object, object> handleReplace = (s, e) => { };
|
||||
|
||||
var item = ResultHelper.CreateResult(
|
||||
4m,
|
||||
@@ -26,7 +27,8 @@ public class CloseOnEnterTests
|
||||
CultureInfo.CurrentCulture,
|
||||
"2+2",
|
||||
settings,
|
||||
handleSave);
|
||||
handleSave,
|
||||
handleReplace);
|
||||
|
||||
Assert.IsNotNull(item);
|
||||
Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand));
|
||||
@@ -41,6 +43,7 @@ public class CloseOnEnterTests
|
||||
{
|
||||
var settings = new Settings(closeOnEnter: false);
|
||||
TypedEventHandler<object, object> handleSave = (s, e) => { };
|
||||
TypedEventHandler<object, object> handleReplace = (s, e) => { };
|
||||
|
||||
var item = ResultHelper.CreateResult(
|
||||
4m,
|
||||
@@ -48,7 +51,8 @@ public class CloseOnEnterTests
|
||||
CultureInfo.CurrentCulture,
|
||||
"2+2",
|
||||
settings,
|
||||
handleSave);
|
||||
handleSave,
|
||||
handleReplace);
|
||||
|
||||
Assert.IsNotNull(item);
|
||||
Assert.IsInstanceOfType(item.Command, typeof(SaveCommand));
|
||||
|
||||
@@ -65,6 +65,9 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
|
||||
["log10(3)", 0.47712125471966M],
|
||||
["ln(e)", 1M],
|
||||
["cosh(0)", 1M],
|
||||
["1*10^(-5)", 0.00001M],
|
||||
["1*10^(-15)", 0.0000000000000001M],
|
||||
["1*10^(-16)", 0M],
|
||||
];
|
||||
|
||||
[DataTestMethod]
|
||||
@@ -192,9 +195,11 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
|
||||
private static IEnumerable<object[]> Interpret_MustReturnExpectedResult_WhenCalled_Data =>
|
||||
[
|
||||
|
||||
// ["factorial(5)", 120M], ToDo: this don't support now
|
||||
// ["sign(-2)", -1M],
|
||||
// ["sign(2)", +1M],
|
||||
["factorial(5)", 120M],
|
||||
["5!", 120M],
|
||||
["(2+3)!", 120M],
|
||||
["sign(-2)", -1M],
|
||||
["sign(2)", +1M],
|
||||
["abs(-2)", 2M],
|
||||
["abs(2)", 2M],
|
||||
["0+(1*2)/(0+1)", 2M], // Validate that division by "(0+1)" is not interpret as division by zero.
|
||||
@@ -221,6 +226,9 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
|
||||
[
|
||||
["0.2E1", "en-US", 2M],
|
||||
["0,2E1", "pt-PT", 2M],
|
||||
["3.5e3 + 2.5E2", "en-US", 3750M],
|
||||
["3,5e3 + 2,5E2", "fr-FR", 3750M],
|
||||
["1E3-1E3/1.5", "en-US", 333.333333333333371M],
|
||||
];
|
||||
|
||||
[DataTestMethod]
|
||||
@@ -389,4 +397,17 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(expectedResult, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("171!")]
|
||||
[DataRow("1000!")]
|
||||
public void Interpret_ReturnsError_WhenValueOverflowsDecimal(string input)
|
||||
{
|
||||
var settings = new Settings();
|
||||
|
||||
CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
|
||||
|
||||
Assert.IsFalse(string.IsNullOrEmpty(error));
|
||||
Assert.AreNotEqual(null, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class IncompleteQueryTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow("2+2+", "2+2")]
|
||||
[DataRow("2+2*", "2+2")]
|
||||
[DataRow("sin(30", "sin(30)")]
|
||||
[DataRow("((1+2)", "((1+2))")]
|
||||
[DataRow("2*(3+4", "2*(3+4)")]
|
||||
[DataRow("(1+2", "(1+2)")]
|
||||
[DataRow("2*(", "2")]
|
||||
[DataRow("2*(((", "2")]
|
||||
public void TestTryGetIncompleteQuerySuccess(string input, string expected)
|
||||
{
|
||||
var result = QueryHelper.TryGetIncompleteQuery(input, out var newQuery);
|
||||
Assert.IsTrue(result);
|
||||
Assert.AreEqual(expected, newQuery);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("")]
|
||||
[DataRow(" ")]
|
||||
public void TestTryGetIncompleteQueryFail(string input)
|
||||
{
|
||||
var result = QueryHelper.TryGetIncompleteQuery(input, out var newQuery);
|
||||
Assert.IsFalse(result);
|
||||
Assert.AreEqual(input, newQuery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class QueryHelperTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow("2²", "4")]
|
||||
[DataRow("2³", "8")]
|
||||
[DataRow("2!", "2")]
|
||||
[DataRow("2\u00A0*\u00A02", "4")] // Non-breaking space
|
||||
[DataRow("20:10", "2")] // Colon as division
|
||||
public void Interpret_HandlesNormalizedInputs(string input, string expected)
|
||||
{
|
||||
var settings = new Settings();
|
||||
var result = QueryHelper.Query(input, settings, false, out _, (_, _) => { });
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(expected, result.Title);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using System.Linq;
|
||||
using Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
using Microsoft.CmdPal.Ext.Calc.Pages;
|
||||
using Microsoft.CmdPal.Ext.UnitTestBase;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
|
||||
@@ -72,7 +71,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
[DataRow("sin(60)", "0.809016", CalculateEngine.TrigMode.Gradians)]
|
||||
public void TrigModeSettingsTest(string input, string expected, CalculateEngine.TrigMode trigMode)
|
||||
{
|
||||
var settings = new Settings(trigUnit: trigMode);
|
||||
var settings = new Settings(trigUnit: trigMode, outputUseEnglishFormat: true);
|
||||
|
||||
var page = new CalculatorListPage(settings);
|
||||
|
||||
|
||||
@@ -12,17 +12,26 @@ public class Settings : ISettingsInterface
|
||||
private readonly bool inputUseEnglishFormat;
|
||||
private readonly bool outputUseEnglishFormat;
|
||||
private readonly bool closeOnEnter;
|
||||
private readonly bool copyResultToSearchBarIfQueryEndsWithEqualSign;
|
||||
private readonly bool autoFixQuery;
|
||||
private readonly bool inputNormalization;
|
||||
|
||||
public Settings(
|
||||
CalculateEngine.TrigMode trigUnit = CalculateEngine.TrigMode.Radians,
|
||||
bool inputUseEnglishFormat = false,
|
||||
bool outputUseEnglishFormat = false,
|
||||
bool closeOnEnter = true)
|
||||
bool closeOnEnter = true,
|
||||
bool copyResultToSearchBarIfQueryEndsWithEqualSign = true,
|
||||
bool autoFixQuery = true,
|
||||
bool inputNormalization = true)
|
||||
{
|
||||
this.trigUnit = trigUnit;
|
||||
this.inputUseEnglishFormat = inputUseEnglishFormat;
|
||||
this.outputUseEnglishFormat = outputUseEnglishFormat;
|
||||
this.closeOnEnter = closeOnEnter;
|
||||
this.copyResultToSearchBarIfQueryEndsWithEqualSign = copyResultToSearchBarIfQueryEndsWithEqualSign;
|
||||
this.autoFixQuery = autoFixQuery;
|
||||
this.inputNormalization = inputNormalization;
|
||||
}
|
||||
|
||||
public CalculateEngine.TrigMode TrigUnit => trigUnit;
|
||||
@@ -32,4 +41,10 @@ public class Settings : ISettingsInterface
|
||||
public bool OutputUseEnglishFormat => outputUseEnglishFormat;
|
||||
|
||||
public bool CloseOnEnter => closeOnEnter;
|
||||
|
||||
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => copyResultToSearchBarIfQueryEndsWithEqualSign;
|
||||
|
||||
public bool AutoFixQuery => autoFixQuery;
|
||||
|
||||
public bool InputNormalization => inputNormalization;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.Legacy;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class FuzzyMatcherComparisonTests
|
||||
{
|
||||
public static IEnumerable<object[]> TestData =>
|
||||
[
|
||||
["a", "a"],
|
||||
["a", "A"],
|
||||
["A", "a"],
|
||||
["abc", "abc"],
|
||||
["abc", "axbycz"],
|
||||
["abc", "abxcyz"],
|
||||
["sln", "solution.sln"],
|
||||
["vs", "visualstudio"],
|
||||
["test", "Test"],
|
||||
["pt", "PowerToys"],
|
||||
["p/t", "power\\toys"],
|
||||
["p\\t", "power/toys"],
|
||||
["c/w", "c:\\windows"],
|
||||
["foo", "bar"],
|
||||
["verylongstringthatdoesnotmatch", "short"],
|
||||
[string.Empty, "anything"],
|
||||
["something", string.Empty],
|
||||
["git", "git"],
|
||||
["em", "Emmy"],
|
||||
["my", "Emmy"],
|
||||
["word", "word"],
|
||||
["wd", "word"],
|
||||
["w d", "word"],
|
||||
["a", "ba"],
|
||||
["a", "ab"],
|
||||
["a", "bab"],
|
||||
["z", "abcdefg"],
|
||||
["CC", "CamelCase"],
|
||||
["cc", "camelCase"],
|
||||
["cC", "camelCase"],
|
||||
["some", "awesome"],
|
||||
["some", "somewhere"],
|
||||
["1", "1"],
|
||||
["1", "2"],
|
||||
[".", "."],
|
||||
["f.t", "file.txt"],
|
||||
["excel", "Excel"],
|
||||
["Excel", "excel"],
|
||||
["PowerPoint", "Power Point"],
|
||||
["power point", "PowerPoint"],
|
||||
["visual studio code", "Visual Studio Code"],
|
||||
["vsc", "Visual Studio Code"],
|
||||
["code", "Visual Studio Code"],
|
||||
["vs code", "Visual Studio Code"],
|
||||
["word", "Microsoft Word"],
|
||||
["ms word", "Microsoft Word"],
|
||||
["browser", "Internet Explorer"],
|
||||
["chrome", "Google Chrome"],
|
||||
["edge", "Microsoft Edge"],
|
||||
["term", "Windows Terminal"],
|
||||
["cmd", "Command Prompt"],
|
||||
["calc", "Calculator"],
|
||||
["snipping", "Snipping Tool"],
|
||||
["note", "Notepad"],
|
||||
["file expl", "File Explorer"],
|
||||
["settings", "Settings"],
|
||||
["p t", "PowerToys"],
|
||||
["p t", "PowerToys"],
|
||||
[" v ", " Visual Studio "],
|
||||
[" a b ", " a b c d "],
|
||||
[string.Empty, string.Empty],
|
||||
[" ", " "],
|
||||
[" ", " "],
|
||||
[" ", "abc"],
|
||||
["abc", " "],
|
||||
[" ", " "],
|
||||
[" ", " a b "],
|
||||
["sh", "ShangHai"],
|
||||
["bj", "BeiJing"],
|
||||
["bj", "北京"],
|
||||
["sh", "上海"],
|
||||
["nh", "你好"],
|
||||
["bj", "Beijing"],
|
||||
["hello", "你好"],
|
||||
["nihao", "你好"],
|
||||
["rmb", "人民币"],
|
||||
["zwr", "中文"],
|
||||
["zw", "中文"],
|
||||
["fbr", "foobar"],
|
||||
["w11", "windows 11"],
|
||||
["pwr", "powershell"],
|
||||
["vm", "void main"],
|
||||
["ps", "PowerShell"],
|
||||
["az", "Azure"],
|
||||
["od", "onedrive"],
|
||||
["gc", "google chrome"],
|
||||
["ff", "firefox"],
|
||||
["fs", "file_system"],
|
||||
["pt", "power-toys"],
|
||||
["jt", "json.test"],
|
||||
["ps", "power shell"],
|
||||
["ps", "power'shell"],
|
||||
["ps", "power\"shell"],
|
||||
["hw", "hello:world"],
|
||||
["abc", "a_b_c"],
|
||||
["abc", "a-b-c"],
|
||||
["abc", "a.b.c"],
|
||||
["abc", "a b c"],
|
||||
["abc", "a'b'c"],
|
||||
["abc", "a\"b\"c"],
|
||||
["abc", "a:b:c"],
|
||||
["_a", "_a"],
|
||||
["a_", "a_"],
|
||||
["-a", "-a"],
|
||||
["a-", "a-"]
|
||||
];
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void CompareScores(string needle, string haystack)
|
||||
{
|
||||
var legacyScore = LegacyFuzzyStringMatcher.ScoreFuzzy(needle, haystack);
|
||||
var newScore = FuzzyStringMatcher.ScoreFuzzy(needle, haystack);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch for needle='{needle}', haystack='{haystack}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void ComparePositions(string needle, string haystack)
|
||||
{
|
||||
var (legacyScore, legacyPos) = LegacyFuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true);
|
||||
var (newScore, newPos) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch (with pos) for needle='{needle}', haystack='{haystack}'");
|
||||
|
||||
// Ensure lists are not null
|
||||
legacyPos ??= [];
|
||||
newPos ??= [];
|
||||
|
||||
// Compare list contents
|
||||
var legacyPosStr = string.Join(',', legacyPos);
|
||||
var newPosStr = string.Join(',', newPos);
|
||||
|
||||
Assert.AreEqual(legacyPos.Count, newPos.Count, $"Position count mismatch: Legacy=[{legacyPosStr}], New=[{newPosStr}]");
|
||||
|
||||
for (var i = 0; i < legacyPos.Count; i++)
|
||||
{
|
||||
Assert.AreEqual(legacyPos[i], newPos[i], $"Position mismatch at index {i}: Legacy=[{legacyPosStr}], New=[{newPosStr}]");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void CompareScores_ContiguousOnly(string needle, string haystack)
|
||||
{
|
||||
var legacyScore = LegacyFuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: false);
|
||||
var newScore = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: false);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch (contiguous only) for needle='{needle}', haystack='{haystack}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void CompareScores_PinyinEnabled(string needle, string haystack)
|
||||
{
|
||||
var originalNew = FuzzyStringMatcher.ChinesePinYinSupport;
|
||||
var originalLegacy = LegacyFuzzyStringMatcher.ChinesePinYinSupport;
|
||||
try
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
LegacyFuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
|
||||
var legacyScore = LegacyFuzzyStringMatcher.ScoreFuzzy(needle, haystack);
|
||||
var newScore = FuzzyStringMatcher.ScoreFuzzy(needle, haystack);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch (Pinyin enabled) for needle='{needle}', haystack='{haystack}'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = originalNew;
|
||||
LegacyFuzzyStringMatcher.ChinesePinYinSupport = originalLegacy;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void ComparePositions_PinyinEnabled(string needle, string haystack)
|
||||
{
|
||||
var originalNew = FuzzyStringMatcher.ChinesePinYinSupport;
|
||||
var originalLegacy = LegacyFuzzyStringMatcher.ChinesePinYinSupport;
|
||||
try
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
LegacyFuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
|
||||
var (legacyScore, legacyPos) = LegacyFuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true);
|
||||
var (newScore, newPos) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch (with pos, Pinyin enabled) for needle='{needle}', haystack='{haystack}'");
|
||||
|
||||
// Ensure lists are not null
|
||||
legacyPos ??= [];
|
||||
newPos ??= [];
|
||||
|
||||
// If newPos is empty but newScore > 0, it means it's a secondary match (like Pinyin)
|
||||
// which we don't return positions for in the new matcher.
|
||||
if (newScore > 0 && newPos.Count == 0 && legacyPos.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare list contents
|
||||
var legacyPosStr = string.Join(',', legacyPos);
|
||||
var newPosStr = string.Join(',', newPos);
|
||||
|
||||
Assert.AreEqual(legacyPos.Count, newPos.Count, $"Position count mismatch: Legacy=[{legacyPosStr}], New=[{newPosStr}]");
|
||||
|
||||
for (var i = 0; i < legacyPos.Count; i++)
|
||||
{
|
||||
Assert.AreEqual(legacyPos[i], newPos[i], $"Position mismatch at index {i}: Legacy=[{legacyPosStr}], New=[{newPosStr}]");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = originalNew;
|
||||
LegacyFuzzyStringMatcher.ChinesePinYinSupport = originalLegacy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class FuzzyMatcherDiacriticsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void ScoreFuzzy_WithDiacriticsRemoval_MatchesWithDiacritics()
|
||||
{
|
||||
// "eco" should match "école" when diacritics are removed (é -> E)
|
||||
var score = FuzzyStringMatcher.ScoreFuzzy("eco", "école", allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
Assert.IsTrue(score > 0, "Should match 'école' with 'eco' when diacritics are removed");
|
||||
|
||||
// "uber" should match "über"
|
||||
score = FuzzyStringMatcher.ScoreFuzzy("uber", "über", allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
Assert.IsTrue(score > 0, "Should match 'über' with 'uber' when diacritics are removed");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ScoreFuzzy_WithoutDiacriticsRemoval_DoesNotMatchWhenCharactersDiffer()
|
||||
{
|
||||
// "eco" should NOT match "école" if 'é' is treated as distinct from 'e' and order is strict
|
||||
// 'é' (index 0) != 'e'. 'e' (index 4) is after 'c' (index 1) and 'o' (index 2).
|
||||
// Since needle is "e-c-o", to match "école":
|
||||
// 'e' matches 'e' at 4.
|
||||
// 'c' must show up after. No.
|
||||
// So no match.
|
||||
var score = FuzzyStringMatcher.ScoreFuzzy("eco", "école", allowNonContiguousMatches: true, removeDiacritics: false);
|
||||
Assert.AreEqual(0, score, "Should not match 'école' with 'eco' when diacritics are NOT removed");
|
||||
|
||||
// "uber" vs "über"
|
||||
// u != ü.
|
||||
// b (index 1) match b (index 2). e (2) match e (3). r (3) match r (4).
|
||||
// but 'u' has no match.
|
||||
score = FuzzyStringMatcher.ScoreFuzzy("uber", "über", allowNonContiguousMatches: true, removeDiacritics: false);
|
||||
Assert.AreEqual(0, score, "Should not match 'über' with 'uber' when diacritics are NOT removed");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ScoreFuzzy_DefaultRemovesDiacritics()
|
||||
{
|
||||
// Now default is true, so "eco" vs "école" should match
|
||||
var score = FuzzyStringMatcher.ScoreFuzzy("eco", "école");
|
||||
Assert.IsTrue(score > 0, "Default should remove diacritics and match 'école'");
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("a", "à", true)]
|
||||
[DataRow("e", "é", true)]
|
||||
[DataRow("i", "ï", true)]
|
||||
[DataRow("o", "ô", true)]
|
||||
[DataRow("u", "ü", true)]
|
||||
[DataRow("c", "ç", true)]
|
||||
[DataRow("n", "ñ", true)]
|
||||
[DataRow("s", "ß", false)] // ß doesn't strip to s via simple invalid-uppercasing
|
||||
public void VerifySpecificCharacters(string needle, string haystack, bool expectingMatch)
|
||||
{
|
||||
var score = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
if (expectingMatch)
|
||||
{
|
||||
Assert.IsTrue(score > 0, $"Expected match for '{needle}' in '{haystack}' with diacritics removal");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.AreEqual(0, score, $"Expected NO match for '{needle}' in '{haystack}' even with diacritics removal");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyBothPathsWorkSameForASCII()
|
||||
{
|
||||
var needle = "test";
|
||||
var haystack = "TestString";
|
||||
|
||||
var score1 = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
var score2 = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: true, removeDiacritics: false);
|
||||
|
||||
Assert.AreEqual(score1, score2, "Scores should be identical for ASCII strings regardless of diacritics setting");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class FuzzyMatcherPinyinLogicTests
|
||||
{
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
FuzzyStringMatcher.ClearCache();
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = false; // Reset to default state
|
||||
FuzzyStringMatcher.ClearCache();
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("bj", "北京")]
|
||||
[DataRow("sh", "上海")]
|
||||
[DataRow("nihao", "你好")]
|
||||
[DataRow("北京", "北京")]
|
||||
[DataRow("北京", "Beijing")]
|
||||
[DataRow("北", "北京")]
|
||||
[DataRow("你好", "nihao")]
|
||||
public void PinyinMatch_DataDriven(string needle, string haystack)
|
||||
{
|
||||
Assert.IsTrue(FuzzyStringMatcher.ScoreFuzzy(needle, haystack) > 0, $"Expected match for '{needle}' in '{haystack}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PinyinPositions_ShouldBeEmpty()
|
||||
{
|
||||
var (score, positions) = FuzzyStringMatcher.ScoreFuzzyWithPositions("bj", "北京", true);
|
||||
Assert.IsTrue(score > 0);
|
||||
Assert.AreEqual(0, positions.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class FuzzyMatcherValidationTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow(null, "haystack")]
|
||||
[DataRow("", "haystack")]
|
||||
[DataRow("needle", null)]
|
||||
[DataRow("needle", "")]
|
||||
[DataRow(null, null)]
|
||||
public void ScoreFuzzy_HandlesIncorrectInputs(string needle, string haystack)
|
||||
{
|
||||
Assert.AreEqual(0, FuzzyStringMatcher.ScoreFuzzy(needle!, haystack!));
|
||||
Assert.AreEqual(0, FuzzyStringMatcher.ScoreFuzzy(needle!, haystack!, allowNonContiguousMatches: true, removeDiacritics: true));
|
||||
Assert.AreEqual(0, FuzzyStringMatcher.ScoreFuzzy(needle!, haystack!, allowNonContiguousMatches: false, removeDiacritics: false));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(null, "haystack")]
|
||||
[DataRow("", "haystack")]
|
||||
[DataRow("needle", null)]
|
||||
[DataRow("needle", "")]
|
||||
[DataRow(null, null)]
|
||||
public void ScoreFuzzyWithPositions_HandlesIncorrectInputs(string needle, string haystack)
|
||||
{
|
||||
var (score1, pos1) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle!, haystack!, true);
|
||||
Assert.AreEqual(0, score1);
|
||||
Assert.IsNotNull(pos1);
|
||||
Assert.AreEqual(0, pos1.Count);
|
||||
|
||||
var (score2, pos2) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle!, haystack!, allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
Assert.AreEqual(0, score2);
|
||||
Assert.IsNotNull(pos2);
|
||||
Assert.AreEqual(0, pos2.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using ToolGood.Words.Pinyin;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.Legacy;
|
||||
|
||||
// Inspired by the fuzzy.rs from edit.exe
|
||||
public static class LegacyFuzzyStringMatcher
|
||||
{
|
||||
private const int NOMATCH = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to support Chinese PinYin.
|
||||
/// Automatically enabled when the system UI culture is Simplified Chinese.
|
||||
/// </summary>
|
||||
public static bool ChinesePinYinSupport { get; set; } = IsSimplifiedChinese();
|
||||
|
||||
private static bool IsSimplifiedChinese()
|
||||
{
|
||||
var culture = CultureInfo.CurrentUICulture;
|
||||
|
||||
// Detect Simplified Chinese: zh-CN, zh-Hans, zh-Hans-*
|
||||
return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase)
|
||||
|| culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static int ScoreFuzzy(string needle, string haystack, bool allowNonContiguousMatches = true)
|
||||
{
|
||||
var (s, _) = ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches);
|
||||
return s;
|
||||
}
|
||||
|
||||
public static (int Score, List<int> Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches)
|
||||
=> ScoreAllFuzzyWithPositions(needle, haystack, allowNonContiguousMatches).MaxBy(i => i.Score);
|
||||
|
||||
public static IEnumerable<(int Score, List<int> Positions)> ScoreAllFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches)
|
||||
{
|
||||
List<string> needles = [needle];
|
||||
List<string> haystacks = [haystack];
|
||||
|
||||
if (ChinesePinYinSupport)
|
||||
{
|
||||
// Remove IME composition split characters.
|
||||
var input = needle.Replace("'", string.Empty);
|
||||
needles.Add(WordsHelper.GetPinyin(input));
|
||||
if (WordsHelper.HasChinese(haystack))
|
||||
{
|
||||
haystacks.Add(WordsHelper.GetPinyin(haystack));
|
||||
}
|
||||
}
|
||||
|
||||
return needles.SelectMany(i => haystacks.Select(j => ScoreFuzzyWithPositionsInternal(i, j, allowNonContiguousMatches)));
|
||||
}
|
||||
|
||||
private static (int Score, List<int> Positions) ScoreFuzzyWithPositionsInternal(string needle, string haystack, bool allowNonContiguousMatches)
|
||||
{
|
||||
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle))
|
||||
{
|
||||
return (NOMATCH, new List<int>());
|
||||
}
|
||||
|
||||
var target = haystack.ToCharArray();
|
||||
var query = needle.ToCharArray();
|
||||
|
||||
if (target.Length < query.Length)
|
||||
{
|
||||
return (NOMATCH, new List<int>());
|
||||
}
|
||||
|
||||
var targetUpper = FoldCase(haystack);
|
||||
var queryUpper = FoldCase(needle);
|
||||
var targetUpperChars = targetUpper.ToCharArray();
|
||||
var queryUpperChars = queryUpper.ToCharArray();
|
||||
|
||||
var area = query.Length * target.Length;
|
||||
var scores = new int[area];
|
||||
var matches = new int[area];
|
||||
|
||||
for (var qi = 0; qi < query.Length; qi++)
|
||||
{
|
||||
var qiOffset = qi * target.Length;
|
||||
var qiPrevOffset = qi > 0 ? (qi - 1) * target.Length : 0;
|
||||
|
||||
for (var ti = 0; ti < target.Length; ti++)
|
||||
{
|
||||
var currentIndex = qiOffset + ti;
|
||||
var diagIndex = (qi > 0 && ti > 0) ? qiPrevOffset + ti - 1 : 0;
|
||||
var leftScore = ti > 0 ? scores[currentIndex - 1] : 0;
|
||||
var diagScore = (qi > 0 && ti > 0) ? scores[diagIndex] : 0;
|
||||
var matchSeqLen = (qi > 0 && ti > 0) ? matches[diagIndex] : 0;
|
||||
|
||||
var score = (diagScore == 0 && qi != 0) ? 0 :
|
||||
ComputeCharScore(
|
||||
query[qi],
|
||||
queryUpperChars[qi],
|
||||
ti != 0 ? target[ti - 1] : null,
|
||||
target[ti],
|
||||
targetUpperChars[ti],
|
||||
matchSeqLen);
|
||||
|
||||
var isValidScore = score != 0 && diagScore + score >= leftScore &&
|
||||
(allowNonContiguousMatches || qi > 0 ||
|
||||
targetUpperChars.Skip(ti).Take(queryUpperChars.Length).SequenceEqual(queryUpperChars));
|
||||
|
||||
if (isValidScore)
|
||||
{
|
||||
matches[currentIndex] = matchSeqLen + 1;
|
||||
scores[currentIndex] = diagScore + score;
|
||||
}
|
||||
else
|
||||
{
|
||||
matches[currentIndex] = NOMATCH;
|
||||
scores[currentIndex] = leftScore;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var positions = new List<int>();
|
||||
if (query.Length > 0 && target.Length > 0)
|
||||
{
|
||||
var qi = query.Length - 1;
|
||||
var ti = target.Length - 1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var index = (qi * target.Length) + ti;
|
||||
if (matches[index] == NOMATCH)
|
||||
{
|
||||
if (ti == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ti--;
|
||||
}
|
||||
else
|
||||
{
|
||||
positions.Add(ti);
|
||||
if (qi == 0 || ti == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
qi--;
|
||||
ti--;
|
||||
}
|
||||
}
|
||||
|
||||
positions.Reverse();
|
||||
}
|
||||
|
||||
return (scores[area - 1], positions);
|
||||
}
|
||||
|
||||
private static string FoldCase(string input)
|
||||
{
|
||||
return input.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static int ComputeCharScore(
|
||||
char query,
|
||||
char queryLower,
|
||||
char? targetPrev,
|
||||
char targetCurr,
|
||||
char targetLower,
|
||||
int matchSeqLen)
|
||||
{
|
||||
if (!ConsiderAsEqual(queryLower, targetLower))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 1; // Character match bonus
|
||||
|
||||
if (matchSeqLen > 0)
|
||||
{
|
||||
score += matchSeqLen * 5; // Consecutive match bonus
|
||||
}
|
||||
|
||||
if (query == targetCurr)
|
||||
{
|
||||
score += 1; // Same case bonus
|
||||
}
|
||||
|
||||
if (targetPrev.HasValue)
|
||||
{
|
||||
var sepBonus = ScoreSeparator(targetPrev.Value);
|
||||
if (sepBonus > 0)
|
||||
{
|
||||
score += sepBonus;
|
||||
}
|
||||
else if (char.IsUpper(targetCurr) && matchSeqLen == 0)
|
||||
{
|
||||
score += 2; // CamelCase bonus
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
score += 8; // Start of word bonus
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool ConsiderAsEqual(char a, char b)
|
||||
{
|
||||
return a == b || (a == '/' && b == '\\') || (a == '\\' && b == '/');
|
||||
}
|
||||
|
||||
private static int ScoreSeparator(char ch)
|
||||
{
|
||||
return ch switch
|
||||
{
|
||||
'/' or '\\' => 5,
|
||||
'_' or '-' or '.' or ' ' or '\'' or '"' or ':' => 4,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<RepoRoot>$(MSBuildThisFileDirectory)..\..\..\..\..\</RepoRoot>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Microsoft.CommandPalette.Extensions.Toolkit.UnitTests</RootNamespace>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(CIBuild)'=='true'">
|
||||
<SignAssembly>true</SignAssembly>
|
||||
<DelaySign>true</DelaySign>
|
||||
<AssemblyOriginatorKeyFile>$(RepoRoot).pipelines\272MSSharedLibSN2048.snk</AssemblyOriginatorKeyFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -53,6 +53,56 @@ public static class BracketHelper
|
||||
return trailTest.Count == 0;
|
||||
}
|
||||
|
||||
public static string BalanceBrackets(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return query ?? string.Empty;
|
||||
}
|
||||
|
||||
var openBrackets = new Stack<TrailType>();
|
||||
|
||||
for (var i = 0; i < query.Length; i++)
|
||||
{
|
||||
var (direction, type) = BracketTrail(query[i]);
|
||||
|
||||
if (direction == TrailDirection.None)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (direction == TrailDirection.Open)
|
||||
{
|
||||
openBrackets.Push(type);
|
||||
}
|
||||
else if (direction == TrailDirection.Close)
|
||||
{
|
||||
// Only pop if we have a matching open bracket
|
||||
if (openBrackets.Count > 0 && openBrackets.Peek() == type)
|
||||
{
|
||||
openBrackets.Pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openBrackets.Count == 0)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
// Build closing brackets in LIFO order
|
||||
var closingBrackets = new char[openBrackets.Count];
|
||||
var index = 0;
|
||||
|
||||
while (openBrackets.Count > 0)
|
||||
{
|
||||
var type = openBrackets.Pop();
|
||||
closingBrackets[index++] = type == TrailType.Round ? ')' : ']';
|
||||
}
|
||||
|
||||
return query + new string(closingBrackets);
|
||||
}
|
||||
|
||||
private static (TrailDirection Direction, TrailType Type) BracketTrail(char @char)
|
||||
{
|
||||
switch (@char)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using CalculatorEngineCommon;
|
||||
@@ -16,6 +15,7 @@ public static class CalculateEngine
|
||||
private static readonly PropertySet _constants = new()
|
||||
{
|
||||
{ "pi", Math.PI },
|
||||
{ "π", Math.PI },
|
||||
{ "e", Math.E },
|
||||
};
|
||||
|
||||
@@ -59,6 +59,8 @@ public static class CalculateEngine
|
||||
|
||||
input = CalculateHelper.FixHumanMultiplicationExpressions(input);
|
||||
|
||||
input = CalculateHelper.UpdateFactorialFunctions(input);
|
||||
|
||||
// Get the user selected trigonometry unit
|
||||
TrigMode trigMode = settings.TrigUnit;
|
||||
|
||||
@@ -77,6 +79,13 @@ public static class CalculateEngine
|
||||
return default;
|
||||
}
|
||||
|
||||
// If we're out of bounds
|
||||
if (result is "inf" or "-inf")
|
||||
{
|
||||
error = Properties.Resources.calculator_not_covert_to_decimal;
|
||||
return default;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(result))
|
||||
{
|
||||
return default;
|
||||
@@ -110,15 +119,19 @@ public static class CalculateEngine
|
||||
/// </summary>
|
||||
public static decimal FormatMax15Digits(decimal value, CultureInfo cultureInfo)
|
||||
{
|
||||
const int maxDisplayDigits = 15;
|
||||
|
||||
if (value == 0m)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var absValue = Math.Abs(value);
|
||||
var integerDigits = absValue >= 1 ? (int)Math.Floor(Math.Log10((double)absValue)) + 1 : 1;
|
||||
|
||||
var maxDecimalDigits = Math.Max(0, 15 - integerDigits);
|
||||
var maxDecimalDigits = Math.Max(0, maxDisplayDigits - integerDigits);
|
||||
|
||||
var rounded = Math.Round(value, maxDecimalDigits, MidpointRounding.AwayFromZero);
|
||||
|
||||
var formatted = rounded.ToString("G29", cultureInfo);
|
||||
|
||||
return Convert.ToDecimal(formatted, cultureInfo);
|
||||
return rounded / 1.000000000000000000000000000000000m;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public static class CalculateHelper
|
||||
public static partial class CalculateHelper
|
||||
{
|
||||
private static readonly Regex RegValidExpressChar = new Regex(
|
||||
@"^(" +
|
||||
@@ -19,7 +20,7 @@ public static class CalculateHelper
|
||||
@"rad\s*\(|deg\s*\(|grad\s*\(|" + /* trigonometry unit conversion macros */
|
||||
@"pi|" +
|
||||
@"==|~=|&&|\|\||" +
|
||||
@"((-?(\d+(\.\d*)?)|-?(\.\d+))[Ee](-?\d+))|" + /* expression from CheckScientificNotation between parenthesis */
|
||||
@"((\d+(?:\.\d*)?|\.\d+)[eE](-?\d+))|" + /* expression from CheckScientificNotation between parenthesis */
|
||||
@"e|[0-9]|0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+|[\+\-\*\/\^\., ""]|[\(\)\|\!\[\]]" +
|
||||
@")+$",
|
||||
RegexOptions.Compiled);
|
||||
@@ -31,6 +32,94 @@ public static class CalculateHelper
|
||||
private const string RadToDeg = "(180 / pi) * ";
|
||||
private const string RadToGrad = "(200 / pi) * ";
|
||||
|
||||
// replacements from the user input to displayed query
|
||||
private static readonly Dictionary<string, string> QueryReplacements = new()
|
||||
{
|
||||
{ "%", "%" }, { "﹪", "%" },
|
||||
{ "−", "-" }, { "–", "-" }, { "—", "-" },
|
||||
{ "!", "!" },
|
||||
{ "*", "×" }, { "∗", "×" }, { "·", "×" }, { "⊗", "×" }, { "⋅", "×" }, { "✕", "×" }, { "✖", "×" }, { "\u2062", "×" },
|
||||
{ "/", "÷" }, { "∕", "÷" }, { "➗", "÷" }, { ":", "÷" },
|
||||
};
|
||||
|
||||
// replacements from a query to engine input
|
||||
private static readonly Dictionary<string, string> EngineReplacements = new()
|
||||
{
|
||||
{ "×", "*" },
|
||||
{ "÷", "/" },
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> SuperscriptReplacements = new()
|
||||
{
|
||||
{ "²", "^2" }, { "³", "^3" },
|
||||
};
|
||||
|
||||
private static readonly HashSet<char> StandardOperators = [
|
||||
|
||||
// binary operators; doesn't make sense for them to be at the end of a query
|
||||
'+', '-', '*', '/', '%', '^', '=', '&', '|', '\\',
|
||||
|
||||
// parentheses
|
||||
'(', '[',
|
||||
];
|
||||
|
||||
private static readonly HashSet<char> SuffixOperators = [
|
||||
|
||||
// unary operators; can appear at the end of a query
|
||||
')', ']', '!',
|
||||
];
|
||||
|
||||
private static readonly Regex ReplaceScientificNotationRegex = CreateReplaceScientificNotationRegex();
|
||||
|
||||
public static char[] GetQueryOperators()
|
||||
{
|
||||
var ops = new HashSet<char>(StandardOperators);
|
||||
ops.ExceptWith(SuffixOperators);
|
||||
return [.. ops];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the query for display
|
||||
/// This replaces standard operators with more visually appealing ones (e.g., '*' -> '×') if enabled.
|
||||
/// Always applies safe normalizations (standardizing variants like minus, percent, etc.).
|
||||
/// </summary>
|
||||
/// <param name="input">The query string to normalize.</param>
|
||||
public static string NormalizeCharsForDisplayQuery(string input)
|
||||
{
|
||||
// 1. Safe/Trivial replacements (Variant -> Standard)
|
||||
// These are always applied to ensure consistent behavior for non-math symbols (spaces) and
|
||||
// operator variants like minus, percent, and exclamation mark.
|
||||
foreach (var (key, value) in QueryReplacements)
|
||||
{
|
||||
input = input.Replace(key, value);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the query for the calculation engine.
|
||||
/// This replaces all supported operator variants (visual or standard) with the specific
|
||||
/// ASCII operators required by the engine (e.g., '×' -> '*').
|
||||
/// It duplicates and expands upon replacements in NormalizeQuery to ensure the engine
|
||||
/// receives valid input regardless of whether NormalizeQuery was executed.
|
||||
/// </summary>
|
||||
public static string NormalizeCharsToEngine(string input)
|
||||
{
|
||||
foreach (var (key, value) in EngineReplacements)
|
||||
{
|
||||
input = input.Replace(key, value);
|
||||
}
|
||||
|
||||
// Replace superscript characters with their engine equivalents (e.g., '²' -> '^2')
|
||||
foreach (var (key, value) in SuperscriptReplacements)
|
||||
{
|
||||
input = input.Replace(key, value);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
public static bool InputValid(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
@@ -50,7 +139,7 @@ public static class CalculateHelper
|
||||
|
||||
// If the input ends with a binary operator then it is not a valid input to mages and the Interpret function would throw an exception. Because we expect here that the user has not finished typing we block those inputs.
|
||||
var trimmedInput = input.TrimEnd();
|
||||
if (trimmedInput.EndsWith('+') || trimmedInput.EndsWith('-') || trimmedInput.EndsWith('*') || trimmedInput.EndsWith('|') || trimmedInput.EndsWith('\\') || trimmedInput.EndsWith('^') || trimmedInput.EndsWith('=') || trimmedInput.EndsWith('&') || trimmedInput.EndsWith('/') || trimmedInput.EndsWith('%'))
|
||||
if (EndsWithBinaryOperator(trimmedInput))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -58,6 +147,18 @@ public static class CalculateHelper
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool EndsWithBinaryOperator(string input)
|
||||
{
|
||||
var operators = GetQueryOperators();
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lastChar = input[^1];
|
||||
return Array.Exists(operators, op => op == lastChar);
|
||||
}
|
||||
|
||||
public static string FixHumanMultiplicationExpressions(string input)
|
||||
{
|
||||
var output = CheckScientificNotation(input);
|
||||
@@ -72,18 +173,7 @@ public static class CalculateHelper
|
||||
|
||||
private static string CheckScientificNotation(string input)
|
||||
{
|
||||
/**
|
||||
* NOTE: By the time that the expression gets to us, it's already in English format.
|
||||
*
|
||||
* Regex explanation:
|
||||
* (-?(\d+({0}\d*)?)|-?({0}\d+)): Used to capture one of two types:
|
||||
* -?(\d+({0}\d*)?): Captures a decimal number starting with a number (e.g. "-1.23")
|
||||
* -?({0}\d+): Captures a decimal number without leading number (e.g. ".23")
|
||||
* e: Captures 'e' or 'E'
|
||||
* (-?\d+): Captures an integer number (e.g. "-1" or "23")
|
||||
*/
|
||||
var p = @"(-?(\d+(\.\d*)?)|-?(\.\d+))e(-?\d+)";
|
||||
return Regex.Replace(input, p, "($1 * 10^($5))", RegexOptions.IgnoreCase);
|
||||
return ReplaceScientificNotationRegex.Replace(input, "($1 * 10^($2))");
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -292,6 +382,86 @@ public static class CalculateHelper
|
||||
return modifiedInput;
|
||||
}
|
||||
|
||||
public static string UpdateFactorialFunctions(string input)
|
||||
{
|
||||
// Handle n! -> factorial(n)
|
||||
int startSearch = 0;
|
||||
while (true)
|
||||
{
|
||||
var index = input.IndexOf('!', startSearch);
|
||||
if (index == -1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Ignore !=
|
||||
if (index + 1 < input.Length && input[index + 1] == '=')
|
||||
{
|
||||
startSearch = index + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index == 0)
|
||||
{
|
||||
startSearch = index + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan backwards
|
||||
var endArg = index - 1;
|
||||
while (endArg >= 0 && char.IsWhiteSpace(input[endArg]))
|
||||
{
|
||||
endArg--;
|
||||
}
|
||||
|
||||
if (endArg < 0)
|
||||
{
|
||||
startSearch = index + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
var startArg = endArg;
|
||||
if (input[endArg] == ')')
|
||||
{
|
||||
// Find matching '('
|
||||
startArg = FindOpeningBracketIndexInFrontOfIndex(input, endArg);
|
||||
if (startArg == -1)
|
||||
{
|
||||
startSearch = index + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scan back for number or word
|
||||
while (startArg >= 0 && (char.IsLetterOrDigit(input[startArg]) || input[startArg] == '.'))
|
||||
{
|
||||
startArg--;
|
||||
}
|
||||
|
||||
startArg++; // Move back to first valid char
|
||||
}
|
||||
|
||||
if (startArg > endArg)
|
||||
{
|
||||
// No argument found
|
||||
startSearch = index + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract argument
|
||||
var arg = input.Substring(startArg, endArg - startArg + 1);
|
||||
|
||||
// Replace <arg><whitespace>! with factorial(<arg>)
|
||||
input = input.Remove(startArg, index - startArg + 1);
|
||||
input = input.Insert(startArg, $"factorial({arg})");
|
||||
|
||||
startSearch = 0; // Reset search because string changed
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
private static string ModifyMathFunction(string input, string function, string modification)
|
||||
{
|
||||
// Create the pattern to match the function, opening bracket, and any spaces in between
|
||||
@@ -325,4 +495,43 @@ public static class CalculateHelper
|
||||
|
||||
return modifiedInput;
|
||||
}
|
||||
|
||||
private static int FindOpeningBracketIndexInFrontOfIndex(string input, int end)
|
||||
{
|
||||
var bracketCount = 0;
|
||||
for (var i = end; i >= 0; i--)
|
||||
{
|
||||
switch (input[i])
|
||||
{
|
||||
case ')':
|
||||
bracketCount++;
|
||||
break;
|
||||
case '(':
|
||||
{
|
||||
bracketCount--;
|
||||
if (bracketCount == 0)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE: By the time that the expression gets to us, it's already in English format.
|
||||
*
|
||||
* Regex explanation:
|
||||
* (-?(\d+({0}\d*)?)|-?({0}\d+)): Used to capture one of two types:
|
||||
* -?(\d+({0}\d*)?): Captures a decimal number starting with a number (e.g. "-1.23")
|
||||
* -?({0}\d+): Captures a decimal number without leading number (e.g. ".23")
|
||||
* e: Captures 'e' or 'E'
|
||||
* (?\d+): Captures an integer number (e.g. "-1" or "23")
|
||||
*/
|
||||
[GeneratedRegex(@"(\d+(?:\.\d*)?|\.\d+)e(-?\d+)", RegexOptions.IgnoreCase, "en-US")]
|
||||
private static partial Regex CreateReplaceScientificNotationRegex();
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public interface ISettingsInterface
|
||||
@@ -15,4 +13,8 @@ public interface ISettingsInterface
|
||||
public bool OutputUseEnglishFormat { get; }
|
||||
|
||||
public bool CloseOnEnter { get; }
|
||||
|
||||
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign { get; }
|
||||
|
||||
public bool AutoFixQuery { get; }
|
||||
}
|
||||
|
||||
@@ -12,7 +12,13 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public static partial class QueryHelper
|
||||
{
|
||||
public static ListItem Query(string query, ISettingsInterface settings, bool isFallbackSearch, TypedEventHandler<object, object> handleSave = null)
|
||||
public static ListItem Query(
|
||||
string query,
|
||||
ISettingsInterface settings,
|
||||
bool isFallbackSearch,
|
||||
out string displayQuery,
|
||||
TypedEventHandler<object, object> handleSave = null,
|
||||
TypedEventHandler<object, object> handleReplace = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!isFallbackSearch)
|
||||
@@ -20,26 +26,50 @@ public static partial class QueryHelper
|
||||
ArgumentNullException.ThrowIfNull(handleSave);
|
||||
}
|
||||
|
||||
CultureInfo inputCulture = settings.InputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
|
||||
CultureInfo outputCulture = settings.OutputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
|
||||
CultureInfo inputCulture =
|
||||
settings.InputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
|
||||
CultureInfo outputCulture =
|
||||
settings.OutputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
|
||||
|
||||
// In case the user pastes a query with a leading =
|
||||
query = query.TrimStart('=');
|
||||
query = query.TrimStart('=').TrimStart();
|
||||
|
||||
// Enables better looking characters for multiplication and division (e.g., '×' and '÷')
|
||||
displayQuery = CalculateHelper.NormalizeCharsForDisplayQuery(query);
|
||||
|
||||
// Happens if the user has only typed the action key so far
|
||||
if (string.IsNullOrEmpty(query))
|
||||
if (string.IsNullOrEmpty(displayQuery))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NumberTranslator translator = NumberTranslator.Create(inputCulture, new CultureInfo("en-US"));
|
||||
var input = translator.Translate(query.Normalize(NormalizationForm.FormKC));
|
||||
// Normalize query to engine format (e.g., replace '×' with '*', converts superscripts to functions)
|
||||
// This must be done before any further normalization to avoid losing information
|
||||
var engineQuery = CalculateHelper.NormalizeCharsToEngine(displayQuery);
|
||||
|
||||
// Cleanup rest of the Unicode characters, whitespace
|
||||
var queryForEngine2 = engineQuery.Normalize(NormalizationForm.FormKC);
|
||||
|
||||
// Translate numbers from input culture to en-US culture for the calculation engine
|
||||
var translator = NumberTranslator.Create(inputCulture, new CultureInfo("en-US"));
|
||||
|
||||
// Translate the input query
|
||||
var input = translator.Translate(queryForEngine2);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return ErrorHandler.OnError(isFallbackSearch, query, Properties.Resources.calculator_expression_empty);
|
||||
}
|
||||
|
||||
// normalize again to engine chars after translation
|
||||
input = CalculateHelper.NormalizeCharsToEngine(input);
|
||||
|
||||
// Auto fix incomplete queries (if enabled)
|
||||
if (settings.AutoFixQuery && TryGetIncompleteQuery(input, out var newInput))
|
||||
{
|
||||
input = newInput;
|
||||
}
|
||||
|
||||
if (!CalculateHelper.InputValid(input))
|
||||
{
|
||||
return null;
|
||||
@@ -60,10 +90,10 @@ public static partial class QueryHelper
|
||||
if (isFallbackSearch)
|
||||
{
|
||||
// Fallback search
|
||||
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, query);
|
||||
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery);
|
||||
}
|
||||
|
||||
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, query, settings, handleSave);
|
||||
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery, settings, handleSave, handleReplace);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
@@ -77,4 +107,32 @@ public static partial class QueryHelper
|
||||
return ErrorHandler.OnError(isFallbackSearch, query, default, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetIncompleteQuery(string query, out string newQuery)
|
||||
{
|
||||
newQuery = query;
|
||||
|
||||
var trimmed = query.TrimEnd();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Trim trailing operators
|
||||
var operators = CalculateHelper.GetQueryOperators();
|
||||
while (trimmed.Length > 0 && Array.IndexOf(operators, trimmed[^1]) > -1)
|
||||
{
|
||||
trimmed = trimmed[..^1].TrimEnd();
|
||||
}
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Fix brackets
|
||||
newQuery = BracketHelper.BalanceBrackets(trimmed);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public sealed partial class ReplaceQueryCommand : InvokableCommand
|
||||
{
|
||||
public event TypedEventHandler<object, object> ReplaceRequested;
|
||||
|
||||
public ReplaceQueryCommand()
|
||||
{
|
||||
Name = "Replace query";
|
||||
Icon = new IconInfo("\uE70F"); // Edit icon
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
ReplaceRequested?.Invoke(this, null);
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Foundation;
|
||||
|
||||
@@ -13,7 +14,14 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public static class ResultHelper
|
||||
{
|
||||
public static ListItem CreateResult(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query, ISettingsInterface settings, TypedEventHandler<object, object> handleSave)
|
||||
public static ListItem CreateResult(
|
||||
decimal? roundedResult,
|
||||
CultureInfo inputCulture,
|
||||
CultureInfo outputCulture,
|
||||
string query,
|
||||
ISettingsInterface settings,
|
||||
TypedEventHandler<object, object> handleSave,
|
||||
TypedEventHandler<object, object> handleReplace)
|
||||
{
|
||||
// Return null when the expression is not a valid calculator query.
|
||||
if (roundedResult is null)
|
||||
@@ -28,6 +36,9 @@ public static class ResultHelper
|
||||
var saveCommand = new SaveCommand(result);
|
||||
saveCommand.SaveRequested += handleSave;
|
||||
|
||||
var replaceCommand = new ReplaceQueryCommand();
|
||||
replaceCommand.ReplaceRequested += handleReplace;
|
||||
|
||||
var copyCommandItem = CreateResult(roundedResult, inputCulture, outputCulture, query);
|
||||
|
||||
// No TextToSuggest on the main save command item. We don't want to keep suggesting what the result is,
|
||||
@@ -40,6 +51,7 @@ public static class ResultHelper
|
||||
Subtitle = query,
|
||||
MoreCommands = [
|
||||
new CommandContextItem(settings.CloseOnEnter ? saveCommand : copyCommandItem.Command),
|
||||
new CommandContextItem(replaceCommand) { RequestedShortcut = KeyChords.CopyResultToSearchBox, },
|
||||
..copyCommandItem.MoreCommands,
|
||||
],
|
||||
};
|
||||
@@ -55,11 +67,15 @@ public static class ResultHelper
|
||||
|
||||
var decimalResult = roundedResult?.ToString(outputCulture);
|
||||
|
||||
List<CommandContextItem> context = [];
|
||||
List<IContextItem> context = [];
|
||||
|
||||
if (decimal.IsInteger((decimal)roundedResult))
|
||||
{
|
||||
context.Add(new Separator());
|
||||
|
||||
var i = decimal.ToInt64((decimal)roundedResult);
|
||||
|
||||
// hexadecimal
|
||||
try
|
||||
{
|
||||
var hexResult = "0x" + i.ToString("X", outputCulture);
|
||||
@@ -70,9 +86,10 @@ public static class ResultHelper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Error parsing hex format", ex);
|
||||
Logger.LogError("Error converting to hex format", ex);
|
||||
}
|
||||
|
||||
// binary
|
||||
try
|
||||
{
|
||||
var binaryResult = "0b" + i.ToString("B", outputCulture);
|
||||
@@ -83,7 +100,21 @@ public static class ResultHelper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Error parsing binary format", ex);
|
||||
Logger.LogError("Error converting to binary format", ex);
|
||||
}
|
||||
|
||||
// octal
|
||||
try
|
||||
{
|
||||
var octalResult = "0o" + Convert.ToString(i, 8);
|
||||
context.Add(new CommandContextItem(new CopyTextCommand(octalResult) { Name = Properties.Resources.calculator_copy_octal })
|
||||
{
|
||||
Title = octalResult,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Error converting to octal format", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,18 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
|
||||
Properties.Resources.calculator_settings_close_on_enter_description,
|
||||
true);
|
||||
|
||||
private readonly ToggleSetting _copyResultToSearchBarIfQueryEndsWithEqualSign = new(
|
||||
Namespaced(nameof(CopyResultToSearchBarIfQueryEndsWithEqualSign)),
|
||||
Properties.Resources.calculator_settings_copy_result_to_search_bar,
|
||||
Properties.Resources.calculator_settings_copy_result_to_search_bar_description,
|
||||
false);
|
||||
|
||||
private readonly ToggleSetting _autoFixQuery = new(
|
||||
Namespaced(nameof(AutoFixQuery)),
|
||||
Properties.Resources.calculator_settings_auto_fix_query,
|
||||
Properties.Resources.calculator_settings_auto_fix_query_description,
|
||||
true);
|
||||
|
||||
public CalculateEngine.TrigMode TrigUnit
|
||||
{
|
||||
get
|
||||
@@ -81,6 +93,10 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
|
||||
|
||||
public bool CloseOnEnter => _closeOnEnter.Value;
|
||||
|
||||
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => _copyResultToSearchBarIfQueryEndsWithEqualSign.Value;
|
||||
|
||||
public bool AutoFixQuery => _autoFixQuery.Value;
|
||||
|
||||
internal static string SettingsJsonPath()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
@@ -98,6 +114,8 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
|
||||
Settings.Add(_inputUseEnNumberFormat);
|
||||
Settings.Add(_outputUseEnNumberFormat);
|
||||
Settings.Add(_closeOnEnter);
|
||||
Settings.Add(_copyResultToSearchBarIfQueryEndsWithEqualSign);
|
||||
Settings.Add(_autoFixQuery);
|
||||
|
||||
// Load settings from file upon initialization
|
||||
LoadSettings();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Windows.System;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc;
|
||||
|
||||
internal static class KeyChords
|
||||
{
|
||||
internal static KeyChord CopyResultToSearchBox { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, (int)VirtualKey.Enter, 0);
|
||||
}
|
||||
@@ -25,12 +25,12 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
private readonly Lock _resultsLock = new();
|
||||
private readonly ISettingsInterface _settingsManager;
|
||||
private readonly List<ListItem> _items = [];
|
||||
private readonly List<ListItem> history = [];
|
||||
private readonly List<ListItem> _history = [];
|
||||
private readonly ListItem _emptyItem;
|
||||
|
||||
// This is the text that saved when the user click the result.
|
||||
// We need to avoid the double calculation. This may cause some wierd behaviors.
|
||||
private string skipQuerySearchText = string.Empty;
|
||||
private string _skipQuerySearchText = string.Empty;
|
||||
|
||||
public CalculatorListPage(ISettingsInterface settings)
|
||||
{
|
||||
@@ -54,6 +54,17 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
UpdateSearchText(string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
private void HandleReplaceQuery(object sender, object args)
|
||||
{
|
||||
var lastResult = _items[0].Title;
|
||||
if (!string.IsNullOrEmpty(lastResult))
|
||||
{
|
||||
_skipQuerySearchText = lastResult;
|
||||
SearchText = lastResult;
|
||||
OnPropertyChanged(nameof(SearchText));
|
||||
}
|
||||
}
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
if (oldSearch == newSearch)
|
||||
@@ -61,19 +72,37 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(skipQuerySearchText) && newSearch == skipQuerySearchText)
|
||||
if (!string.IsNullOrEmpty(_skipQuerySearchText) && newSearch == _skipQuerySearchText)
|
||||
{
|
||||
// only skip once.
|
||||
skipQuerySearchText = string.Empty;
|
||||
_skipQuerySearchText = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
skipQuerySearchText = string.Empty;
|
||||
var copyResultToSearchText = false;
|
||||
if (_settingsManager.CopyResultToSearchBarIfQueryEndsWithEqualSign && newSearch.EndsWith('='))
|
||||
{
|
||||
newSearch = newSearch.TrimEnd('=').TrimEnd();
|
||||
copyResultToSearchText = true;
|
||||
}
|
||||
|
||||
_skipQuerySearchText = string.Empty;
|
||||
|
||||
_emptyItem.Subtitle = newSearch;
|
||||
|
||||
var result = QueryHelper.Query(newSearch, _settingsManager, false, HandleSave);
|
||||
var result = QueryHelper.Query(newSearch, _settingsManager, isFallbackSearch: false, out var displayQuery, HandleSave, HandleReplaceQuery);
|
||||
|
||||
UpdateResult(result);
|
||||
|
||||
if (copyResultToSearchText && result is not null)
|
||||
{
|
||||
_skipQuerySearchText = result.Title;
|
||||
SearchText = result.Title;
|
||||
|
||||
// LOAD BEARING: The SearchText setter does not raise a PropertyChanged notification,
|
||||
// so we must raise it explicitly to ensure the UI updates correctly.
|
||||
OnPropertyChanged(nameof(SearchText));
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateResult(ListItem result)
|
||||
@@ -91,7 +120,7 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
_items.Add(_emptyItem);
|
||||
}
|
||||
|
||||
this._items.AddRange(history);
|
||||
this._items.AddRange(_history);
|
||||
}
|
||||
|
||||
RaiseItemsChanged(this._items.Count);
|
||||
@@ -109,7 +138,7 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
TextToSuggest = lastResult,
|
||||
};
|
||||
|
||||
history.Insert(0, li);
|
||||
_history.Insert(0, li);
|
||||
_items.Insert(1, li);
|
||||
|
||||
// Why we need to clean the query record? Removed, but if necessary, please move it back.
|
||||
@@ -117,9 +146,14 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
|
||||
// this change will call the UpdateSearchText again.
|
||||
// We need to avoid it.
|
||||
skipQuerySearchText = lastResult;
|
||||
_skipQuerySearchText = lastResult;
|
||||
SearchText = lastResult;
|
||||
this.RaiseItemsChanged(this._items.Count);
|
||||
|
||||
// LOAD BEARING: The SearchText setter does not raise a PropertyChanged notification,
|
||||
// so we must raise it explicitly to ensure the UI updates correctly.
|
||||
OnPropertyChanged(nameof(SearchText));
|
||||
|
||||
RaiseItemsChanged(this._items.Count);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed partial class FallbackCalculatorItem : FallbackCommandItem
|
||||
|
||||
public override void UpdateQuery(string query)
|
||||
{
|
||||
var result = QueryHelper.Query(query, _settings, true, null);
|
||||
var result = QueryHelper.Query(query, _settings, true, out _);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
public class Resources {
|
||||
@@ -96,6 +96,15 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Copy octal.
|
||||
/// </summary>
|
||||
public static string calculator_copy_octal {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_copy_octal", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Calculator.
|
||||
/// </summary>
|
||||
@@ -186,6 +195,24 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Fix incomplete calculations automatically.
|
||||
/// </summary>
|
||||
public static string calculator_settings_auto_fix_query {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_auto_fix_query", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Attempt to evaluate incomplete calculations by ignoring extra operators or symbols.
|
||||
/// </summary>
|
||||
public static string calculator_settings_auto_fix_query_description {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_auto_fix_query_description", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Close on Enter.
|
||||
/// </summary>
|
||||
@@ -204,6 +231,24 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Replace query with result on equals.
|
||||
/// </summary>
|
||||
public static string calculator_settings_copy_result_to_search_bar {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_copy_result_to_search_bar", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Updates the query to the result when (=) is entered.
|
||||
/// </summary>
|
||||
public static string calculator_settings_copy_result_to_search_bar_description {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_copy_result_to_search_bar_description", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Use English (United States) number format for input.
|
||||
/// </summary>
|
||||
@@ -222,6 +267,24 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Handle extra operators and symbols.
|
||||
/// </summary>
|
||||
public static string calculator_settings_input_normalization {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_input_normalization", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Enable advanced input normalization and extra symbols (e.g. ÷, ×, π).
|
||||
/// </summary>
|
||||
public static string calculator_settings_input_normalization_description {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_input_normalization_description", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Use English (United States) number format for output.
|
||||
/// </summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user