mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-04 09:30:04 +02:00
Compare commits
34 Commits
user/muyua
...
workspaces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad023a34d | ||
|
|
d25bf607bc | ||
|
|
c31800bddf | ||
|
|
50f7e96825 | ||
|
|
e232616ad3 | ||
|
|
b0cccf87bd | ||
|
|
ab96a61aa3 | ||
|
|
0106c0ba59 | ||
|
|
de4859454c | ||
|
|
93669df118 | ||
|
|
56fabda79c | ||
|
|
70555459ab | ||
|
|
d6319516d0 | ||
|
|
53737cbe31 | ||
|
|
f6a81a4235 | ||
|
|
bf6ff579d3 | ||
|
|
0afe525f31 | ||
|
|
a43fb12d6f | ||
|
|
bc56443443 | ||
|
|
3298625b67 | ||
|
|
ae9f241ef1 | ||
|
|
67a9fa2d13 | ||
|
|
1cfc923bdb | ||
|
|
2dd802f367 | ||
|
|
a0d17406ba | ||
|
|
4a27c5d5f9 | ||
|
|
f94c439a8d | ||
|
|
a6f4357c94 | ||
|
|
6961fc66d0 | ||
|
|
8cd88f9817 | ||
|
|
57bdc9da6e | ||
|
|
81ee6b6efd | ||
|
|
ed1570a0e3 | ||
|
|
b364da81e8 |
2
.github/actions/spell-check/expect.txt
vendored
2
.github/actions/spell-check/expect.txt
vendored
@@ -1234,6 +1234,8 @@ NOTSRCCOPY
|
||||
NOTSRCERASE
|
||||
Notupdated
|
||||
notwindows
|
||||
NOTXORPEN
|
||||
Nouveaut
|
||||
nowarn
|
||||
NOZORDER
|
||||
NPH
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: wpf-to-winui3-migration
|
||||
description: Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.
|
||||
description: 'Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.'
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
|
||||
@@ -234,8 +234,8 @@
|
||||
"PowerToys.WorkspacesWindowArranger.exe",
|
||||
"PowerToys.WorkspacesEditor.exe",
|
||||
"PowerToys.WorkspacesEditor.dll",
|
||||
"PowerToys.WorkspacesLauncherUI.exe",
|
||||
"PowerToys.WorkspacesLauncherUI.dll",
|
||||
"WinUI3Apps\\PowerToys.WorkspacesLauncherUI.exe",
|
||||
"WinUI3Apps\\PowerToys.WorkspacesLauncherUI.dll",
|
||||
"PowerToys.WorkspacesModuleInterface.dll",
|
||||
"PowerToys.WorkspacesCsharpLibrary.dll",
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260116-build.2514" />
|
||||
<PackageVersion Include="ControlzEx" Version="6.0.0" />
|
||||
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
|
||||
@@ -38,7 +38,7 @@
|
||||
<PackageVersion Include="Mages" Version="3.0.0" />
|
||||
<PackageVersion Include="Markdig.Signed" Version="0.34.0" />
|
||||
<!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. -->
|
||||
<PackageVersion Include="MessagePack" Version="3.1.3" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.7" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.102" />
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||
@@ -64,7 +64,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.71.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.71.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
|
||||
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
|
||||
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
|
||||
@@ -76,7 +76,7 @@
|
||||
This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail.
|
||||
-->
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1"/>
|
||||
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="2.1.0" />
|
||||
@@ -151,4 +151,4 @@
|
||||
<PackageVersion Include="Microsoft.VariantAssignment.Client" Version="2.4.17140001" />
|
||||
<PackageVersion Include="Microsoft.VariantAssignment.Contract" Version="3.0.16990001" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1027,7 +1027,7 @@
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj" Id="2cac093e-5fcf-4102-9c2c-ac7dd5d9eb96" />
|
||||
<Project Path="src/modules/Workspaces/WorkspacesLauncherUI/WorkspacesLauncherUI.csproj">
|
||||
<Project Path="src/modules/Workspaces/WorkspacesLauncherUI.WinUI/WorkspacesLauncherUI.WinUI.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
@@ -1110,6 +1110,14 @@
|
||||
<File Path="src/Solution.props" />
|
||||
<File Path="src/Version.props" />
|
||||
</Folder>
|
||||
<Folder Name="/src/" />
|
||||
<Folder Name="/src/modules/" />
|
||||
<Folder Name="/src/modules/Workspaces/">
|
||||
<Project Path="src/modules/Workspaces/WorkspacesLauncherUI.UnitTests/WorkspacesLauncherUI.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Project Path="src/ActionRunner/ActionRunner.vcxproj" Id="d29ddd63-e2cf-4657-9fd5-2aede4257e5d">
|
||||
<BuildDependency Project="src/common/updating/updating.vcxproj" />
|
||||
</Project>
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
# Advanced Paste – Python Scripts
|
||||
|
||||
Advanced Paste supports user-defined Python scripts that transform clipboard content. Scripts are
|
||||
discovered automatically from a configurable folder and appear as actions in the Advanced Paste UI.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Open the scripts folder — by default `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts`.
|
||||
You can change this in **Settings → Advanced Paste → Python scripts → Scripts folder**.
|
||||
2. Drop a `.py` file into the folder.
|
||||
3. Define one `advanced_paste_from_<input>_to_<output>` function (see [Writing a script](#writing-a-script)).
|
||||
4. Open the Advanced Paste UI (`Win+Shift+V`) — your script will appear in the action list.
|
||||
|
||||
> **Important:** Each `.py` file must define exactly one `advanced_paste_from_<input>_to_<output>`
|
||||
> function. Scripts with zero or multiple such functions are ignored.
|
||||
|
||||
## Writing a script
|
||||
|
||||
You write a single Python function whose **name** declares both what clipboard input it accepts
|
||||
and what output type it produces.
|
||||
No imports from PowerToys are needed — zero setup, zero dependencies on our side.
|
||||
|
||||
### Function naming convention
|
||||
|
||||
The function name follows the pattern:
|
||||
|
||||
```
|
||||
advanced_paste_from_<input>_to_<output>(<param>)
|
||||
```
|
||||
|
||||
**Input types** (what the function receives):
|
||||
|
||||
| Input | Parameter | When it runs |
|
||||
|-------|-----------|--------------|
|
||||
| `text` | `str` — clipboard text | Clipboard has text |
|
||||
| `html` | `str` — clipboard HTML | Clipboard has HTML |
|
||||
| `image` | `str` — path to temp image file | Clipboard has an image |
|
||||
| `audio` | `str` — path to audio file | Clipboard has an audio file |
|
||||
| `video` | `str` — path to video file | Clipboard has a video file |
|
||||
| `files` | `list[str]` — file paths | Clipboard has files |
|
||||
|
||||
**Output types** (what the function produces — declared via `_to_` suffix):
|
||||
|
||||
| Output | Effect |
|
||||
|--------|--------|
|
||||
| `text` | Sets clipboard to text |
|
||||
| `html` | Sets clipboard to HTML |
|
||||
| `image` | Sets clipboard to image |
|
||||
| `audio` | Sets clipboard to audio file |
|
||||
| `video` | Sets clipboard to video file |
|
||||
| `file` | Sets clipboard to a file |
|
||||
| `files` | Sets clipboard to multiple files |
|
||||
|
||||
### Return value
|
||||
|
||||
The return value is interpreted according to the declared output type:
|
||||
|
||||
| Output type | Expected return value |
|
||||
|-------------|---------------------|
|
||||
| `text` | `str` (or any value — will be converted via `str()`) |
|
||||
| `html` | `str` containing HTML |
|
||||
| `image` | `str` or `pathlib.Path` pointing to an image file |
|
||||
| `file` | `str` or `pathlib.Path` pointing to a file |
|
||||
| `files` | `list` of `str`/`pathlib.Path` file paths |
|
||||
|
||||
Returning `None` produces an empty result (no-op).
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal — uppercase text
|
||||
|
||||
```python
|
||||
def advanced_paste_from_text_to_text(text):
|
||||
return text.upper()
|
||||
```
|
||||
|
||||
That's it. No headers required, no imports from PowerToys.
|
||||
|
||||
### With optional metadata
|
||||
|
||||
```python
|
||||
# @advancedpaste:name Reverse Text
|
||||
# @advancedpaste:desc Reverses clipboard text character by character
|
||||
|
||||
def advanced_paste_from_text_to_text(text):
|
||||
return text[::-1]
|
||||
```
|
||||
|
||||
### Text to HTML
|
||||
|
||||
```python
|
||||
# @advancedpaste:name Markdown Table to HTML
|
||||
# @advancedpaste:desc Convert a markdown table to an HTML table
|
||||
|
||||
def advanced_paste_from_text_to_html(text):
|
||||
headers = text.splitlines()[0].split("|")
|
||||
return f"<table><tr>{''.join(f'<th>{h.strip()}</th>' for h in headers if h.strip())}</tr></table>"
|
||||
```
|
||||
|
||||
### Image to text (OCR)
|
||||
|
||||
```python
|
||||
# @advancedpaste:requires pytesseract
|
||||
|
||||
def advanced_paste_from_image_to_text(image_path):
|
||||
import pytesseract
|
||||
return pytesseract.image_to_string(image_path).strip()
|
||||
```
|
||||
|
||||
### Save text as file
|
||||
|
||||
```python
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
def advanced_paste_from_text_to_file(text):
|
||||
# Use ADVANCED_PASTE_WORK_DIR for WSL compatibility; falls back to temp dir on Windows.
|
||||
out_dir = os.environ.get("ADVANCED_PASTE_WORK_DIR") or tempfile.gettempdir()
|
||||
out = Path(out_dir) / "clipboard.txt"
|
||||
out.write_text(text, encoding="utf-8")
|
||||
return out
|
||||
```
|
||||
|
||||
### Image processing (image → image)
|
||||
|
||||
```python
|
||||
import os
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
def advanced_paste_from_image_to_image(image_path):
|
||||
"""Convert image to grayscale."""
|
||||
img = Image.open(image_path).convert("L")
|
||||
out_dir = os.environ.get("ADVANCED_PASTE_WORK_DIR") or tempfile.gettempdir()
|
||||
out = Path(out_dir) / "gray.png"
|
||||
img.save(out)
|
||||
return out
|
||||
```
|
||||
|
||||
### File listing (files → text)
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
def advanced_paste_from_files_to_text(file_paths):
|
||||
lines = []
|
||||
for p in file_paths:
|
||||
size = os.path.getsize(p)
|
||||
lines.append(f"{os.path.basename(p)} ({size} bytes)")
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
## Header tags
|
||||
|
||||
All header tags are **optional**. Tags are placed in comment lines at the top of the script.
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `name` | Display name in the Advanced Paste UI. If omitted, the filename is used. |
|
||||
| `desc` | Short description / tooltip. |
|
||||
| `disabled` | Presence of this tag disables the script (it won't appear in the UI). |
|
||||
| `requires` | Declare Python package dependencies (see [Dependencies](#declaring-dependencies)). |
|
||||
|
||||
### Example header
|
||||
|
||||
```python
|
||||
# @advancedpaste:name My Formatter
|
||||
# @advancedpaste:desc Formats clipboard text as markdown table
|
||||
```
|
||||
|
||||
To disable a script without deleting it, add:
|
||||
|
||||
```python
|
||||
# @advancedpaste:disabled
|
||||
```
|
||||
|
||||
Remove the line to re-enable.
|
||||
|
||||
## Declaring dependencies
|
||||
|
||||
Use `requires` to declare Python packages the script needs:
|
||||
|
||||
```python
|
||||
# @advancedpaste:requires PIL=Pillow
|
||||
# @advancedpaste:requires cv2=opencv-python-headless numpy requests
|
||||
```
|
||||
|
||||
Each token is either:
|
||||
|
||||
- **`import_name`** — the pip package is assumed to have the same name (e.g. `requests`).
|
||||
- **`import_name=pip_package`** — when the import name differs from the pip package
|
||||
(e.g. `cv2=opencv-python-headless`, `PIL=Pillow`).
|
||||
|
||||
### Automatic import detection
|
||||
|
||||
Advanced Paste also scans the script body for `import` and `from ... import` statements
|
||||
and cross-references them against the Python standard library. Any non-stdlib import
|
||||
that is not already installed triggers a prompt to install it automatically.
|
||||
|
||||
## Security — script trust
|
||||
|
||||
The first time a script is executed (or after it has been modified), Advanced Paste
|
||||
shows a confirmation dialog. Upon approval the SHA-256 hash of the script is stored.
|
||||
Subsequent runs of the unchanged file skip the dialog.
|
||||
|
||||
## Error handling
|
||||
|
||||
When a script fails, Advanced Paste extracts the Python traceback from stderr and
|
||||
displays a user-friendly summary in the UI:
|
||||
|
||||
- **ModuleNotFoundError** — identifies the missing module and suggests installing it.
|
||||
- **SyntaxError** — shows the file and line number.
|
||||
- **Timeout** — shows the configured timeout value (default 30 s; configurable in Settings).
|
||||
- **Other errors** — shows the last line of the traceback as a summary, with the full
|
||||
traceback available in the expandable *Details* section.
|
||||
|
||||
## Settings
|
||||
|
||||
The Python scripts feature uses a **mode selector** (dropdown) with three options:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| **Disabled** | Python scripts are not active. |
|
||||
| **Windows** | Scripts run using a native Windows Python interpreter. |
|
||||
| **WSL** | Scripts run inside Windows Subsystem for Linux. |
|
||||
|
||||
Each mode maintains its own independent settings (scripts folder, interpreter path, etc.),
|
||||
so switching between Windows and WSL does not lose your previous configuration.
|
||||
|
||||
### Windows mode settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Scripts folder | Folder to scan for `.py` scripts. | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
|
||||
| Python interpreter | Path to the Python executable. Leave blank for auto-detection. | *(auto-detect)* |
|
||||
|
||||
### WSL mode settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Scripts folder | Folder to scan for `.py` scripts (Windows path — auto-translated to `/mnt/...`). | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
|
||||
| WSL distribution | Which WSL distro to use (e.g. `Ubuntu`). Leave blank for the default distribution. | *(default)* |
|
||||
|
||||
### Scripts list
|
||||
|
||||
The Settings page shows a read-only list of discovered scripts. For each script you can see:
|
||||
|
||||
- **Name** — from `@advancedpaste:name` tag, or the filename if not set.
|
||||
- **Description** — from `@advancedpaste:desc` tag.
|
||||
- **Conversion** — the input → output types detected from the function name (e.g. "text → image").
|
||||
|
||||
The list is **not editable** from Settings. To change a script's name, description, enabled state,
|
||||
or any other metadata, open the script file directly (click the "Open in editor" button) and edit
|
||||
the `# @advancedpaste:...` header tags. After saving, click **Refresh** in Settings to reload.
|
||||
|
||||
### WSL mode details
|
||||
|
||||
When **WSL** mode is selected:
|
||||
|
||||
- Scripts are executed via `wsl.exe bash -l -c "python3 ..."` using the configured distribution.
|
||||
- The scripts folder remains on the Windows filesystem; paths are automatically translated
|
||||
to `/mnt/c/...` format for WSL access.
|
||||
- Package installation uses `pip3 install` inside the WSL environment.
|
||||
- Output files from scripts must be written under `/mnt/` (the Windows-mounted filesystem)
|
||||
so they can be accessed from Windows. The runner sets the `ADVANCED_PASTE_WORK_DIR` environment
|
||||
variable to a temp directory under `/mnt/c/...` — use it instead of `tempfile.gettempdir()`
|
||||
when producing file output for cross-platform compatibility.
|
||||
|
||||
> **Tip:** If you have Python installed only in WSL (not on Windows), select WSL mode
|
||||
> to use your existing WSL Python environment with all its packages.
|
||||
|
||||
## Tips
|
||||
|
||||
- Each `.py` file must contain exactly one `advanced_paste_from_<input>_to_<output>` function.
|
||||
If you need to handle multiple input types, create separate script files for each.
|
||||
- A `.py` file without any matching function is ignored — use this for helper modules
|
||||
that other scripts can import.
|
||||
- Scripts can be tested from the command line:
|
||||
```
|
||||
echo {"format":["text"],"text":"hello"} | python _runner.py my_script.py
|
||||
```
|
||||
- The script's directory is added to `sys.path` at runtime, so you can import sibling `.py`
|
||||
files as helper modules.
|
||||
@@ -1,5 +0,0 @@
|
||||
# [CleanUp_tool](/tools/CleanUp_tool/) and [CleanUp_tool_powershell_script](/tools/CleanUp_tool_powershell_script/CleanUp_tool.ps1)
|
||||
|
||||
This tool, respective this powershell script, is used to clean up the PowerToys installation. It cleans the `AppData` folder and the registry.
|
||||
|
||||
This tool is currently very outdated and just cleans up the registry keys of some few modules.
|
||||
@@ -10,7 +10,6 @@ Following tools are currently available:
|
||||
|
||||
* [BugReportTool](bug-report-tool.md) - A tool to collect logs and system information for bug reports.
|
||||
* [Build tools](build-tools.md) - A set of scripts that help building PowerToys.
|
||||
* [Clean up tool](clean-up-tool.md) - A tool to clean up the PowerToys installation.
|
||||
* [Monitor info report](monitor-info-report.md) - A small diagnostic tool which helps identifying WinAPI bugs related to the physical monitor detection.
|
||||
* [project template](/tools/project_template/README.md) - A Visual Studio project template for a new PowerToys project.
|
||||
* [StylesReportTool](styles-report-tool.md) - A tool to collect information about an open window.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
|
||||
</packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
|
||||
</packages>
|
||||
@@ -5,6 +5,8 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Windows.Foundation;
|
||||
using WinUIEx;
|
||||
|
||||
@@ -37,6 +39,18 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Window;
|
||||
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> is delayed until the
|
||||
/// content has finished animating out. With no listener the window simply shows
|
||||
/// or hides immediately.</para>
|
||||
/// <para><b>Multiple surfaces.</b> More than one <see cref="TransientSurface"/>
|
||||
/// may host on the same window by each calling
|
||||
/// <see cref="TransientSurface.SubscribeTo"/>. The <see cref="Showing"/> and
|
||||
/// <see cref="Hiding"/> events are simply raised for every subscriber, and
|
||||
/// because <see cref="HidingEventArgs"/> aggregates deferrals the underlying
|
||||
/// window is hidden only after <em>all</em> surfaces have finished animating
|
||||
/// out. To let each surface play its own distinct transition, call the
|
||||
/// parameterless <see cref="Show()"/> (so every surface uses its configured
|
||||
/// <c>ShowTransition</c>/<c>HideTransition</c>); the <see cref="Show(Transition)"/>
|
||||
/// overload instead broadcasts a single transition to all surfaces. Sizing the
|
||||
/// window and positioning each surface within it remain the consumer's
|
||||
/// responsibility (this window owns no layout).</para>
|
||||
/// </remarks>
|
||||
public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
{
|
||||
@@ -52,6 +66,9 @@ public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
|
||||
private readonly nint _hwnd;
|
||||
|
||||
private bool _inputHooked;
|
||||
private bool _seenActivated;
|
||||
|
||||
public TransparentWindow()
|
||||
{
|
||||
AppWindow.Hide();
|
||||
@@ -74,8 +91,30 @@ public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
ApplyExStyleBit(WsExToolWindow, true);
|
||||
|
||||
SystemBackdrop = new TransparentTintBackdrop();
|
||||
|
||||
Activated += OnActivatedForDismiss;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether pressing <c>Esc</c> while the
|
||||
/// window content has keyboard focus dismisses the window (<see cref="Hide"/>).
|
||||
/// Defaults to <see langword="false"/>. The window is shown without
|
||||
/// activation, so the consumer must activate it for its content to receive
|
||||
/// keyboard input.
|
||||
/// </summary>
|
||||
public bool DismissOnEscape { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the window dismisses itself
|
||||
/// (<see cref="Hide"/>) when it loses focus (is deactivated), i.e. light
|
||||
/// dismiss. Defaults to <see langword="false"/>. Only takes effect after the
|
||||
/// window has been activated at least once since the last <see cref="Show()"/>,
|
||||
/// so the transient deactivation that can occur during the show sequence does
|
||||
/// not dismiss it prematurely. The window is shown without activation, so the
|
||||
/// consumer must activate it for this to apply.
|
||||
/// </summary>
|
||||
public bool DismissOnFocusLost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised (without activation) when <see cref="Show()"/> makes the window
|
||||
/// visible. A content surface subscribes to this to play its in-animation,
|
||||
@@ -112,6 +151,8 @@ public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
_seenActivated = false;
|
||||
EnsureInputHooks();
|
||||
_ = ShowWindow(_hwnd, SwShowNa);
|
||||
Showing?.Invoke(this, new ShowingEventArgs(transition));
|
||||
});
|
||||
@@ -134,6 +175,41 @@ public partial class TransparentWindow : WinUIEx.WindowEx
|
||||
});
|
||||
}
|
||||
|
||||
private void OnActivatedForDismiss(object sender, WindowActivatedEventArgs args)
|
||||
{
|
||||
if (args.WindowActivationState == WindowActivationState.Deactivated)
|
||||
{
|
||||
if (DismissOnFocusLost && _seenActivated)
|
||||
{
|
||||
Hide();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_seenActivated = true;
|
||||
}
|
||||
|
||||
private void EnsureInputHooks()
|
||||
{
|
||||
if (_inputHooked || Content is not UIElement element)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
element.KeyDown += OnContentKeyDown;
|
||||
_inputHooked = true;
|
||||
}
|
||||
|
||||
private void OnContentKeyDown(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
if (DismissOnEscape && e.Key == global::Windows.System.VirtualKey.Escape)
|
||||
{
|
||||
e.Handled = true;
|
||||
Hide();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyExStyleBit(int bit, bool set)
|
||||
{
|
||||
if (_hwnd == 0)
|
||||
|
||||
@@ -232,10 +232,6 @@ namespace winrt::PowerToys::GPOWrapper::implementation
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteFoundryLocalValue());
|
||||
}
|
||||
GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPastePythonScriptsValue()
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPastePythonScriptsValue());
|
||||
}
|
||||
GpoRuleConfigured GPOWrapper::GetConfiguredNewPlusEnabledValue()
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredNewPlusEnabledValue());
|
||||
|
||||
@@ -64,7 +64,6 @@ namespace winrt::PowerToys::GPOWrapper::implementation
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPastePythonScriptsValue();
|
||||
static GpoRuleConfigured GetConfiguredNewPlusEnabledValue();
|
||||
static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue();
|
||||
static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue();
|
||||
|
||||
@@ -68,7 +68,6 @@ namespace PowerToys
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPastePythonScriptsValue();
|
||||
static GpoRuleConfigured GetConfiguredNewPlusEnabledValue();
|
||||
static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue();
|
||||
static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue();
|
||||
|
||||
@@ -93,7 +93,6 @@ namespace powertoys_gpo
|
||||
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_GOOGLE = L"AllowAdvancedPasteGoogle";
|
||||
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OLLAMA = L"AllowAdvancedPasteOllama";
|
||||
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL = L"AllowAdvancedPasteFoundryLocal";
|
||||
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_PYTHON_SCRIPTS = L"AllowAdvancedPastePythonScripts";
|
||||
const std::wstring POLICY_MWB_CLIPBOARD_SHARING_ENABLED = L"MwbClipboardSharingEnabled";
|
||||
const std::wstring POLICY_MWB_FILE_TRANSFER_ENABLED = L"MwbFileTransferEnabled";
|
||||
const std::wstring POLICY_MWB_USE_ORIGINAL_USER_INTERFACE = L"MwbUseOriginalUserInterface";
|
||||
@@ -638,11 +637,6 @@ namespace powertoys_gpo
|
||||
return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL);
|
||||
}
|
||||
|
||||
inline gpo_rule_configured_t getAllowedAdvancedPastePythonScriptsValue()
|
||||
{
|
||||
return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_PYTHON_SCRIPTS);
|
||||
}
|
||||
|
||||
inline gpo_rule_configured_t getConfiguredMwbClipboardSharingEnabledValue()
|
||||
{
|
||||
return getConfiguredValue(POLICY_MWB_CLIPBOARD_SHARING_ENABLED);
|
||||
|
||||
@@ -718,16 +718,6 @@
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<policy name="AllowAdvancedPastePythonScripts" class="Both" displayName="$(string.AllowAdvancedPastePythonScripts)" explainText="$(string.AllowAdvancedPastePythonScriptsDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPastePythonScripts">
|
||||
<parentCategory ref="AdvancedPaste" />
|
||||
<supportedOn ref="SUPPORTED_POWERTOYS_0_99_0" />
|
||||
<enabledValue>
|
||||
<decimal value="1" />
|
||||
</enabledValue>
|
||||
<disabledValue>
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<policy name="MwbClipboardSharingEnabled" class="Both" displayName="$(string.MwbClipboardSharingEnabled)" explainText="$(string.MwbClipboardSharingEnabledDescription)" key="Software\Policies\PowerToys" valueName="MwbClipboardSharingEnabled">
|
||||
<parentCategory ref="MouseWithoutBorders" />
|
||||
<supportedOn ref="SUPPORTED_POWERTOYS_0_83_0" />
|
||||
|
||||
@@ -345,12 +345,6 @@ If you disable this policy, users will not be able to select or use Ollama endpo
|
||||
If you enable or don't configure this policy, users can configure and use Foundry Local as their AI provider.
|
||||
|
||||
If you disable this policy, users will not be able to select or use Foundry Local endpoint in Advanced Paste settings.</string>
|
||||
<string id="AllowAdvancedPastePythonScripts">Advanced Paste: Allow Python scripts</string>
|
||||
<string id="AllowAdvancedPastePythonScriptsDescription">This policy controls whether users can enable and execute Python scripts in Advanced Paste.
|
||||
|
||||
If you enable or don't configure this policy, users can enable the Python scripts feature (Disabled/Windows/WSL modes) and run custom Python scripts for clipboard transformations.
|
||||
|
||||
If you disable this policy, the Python scripts mode selector will be forced to Disabled and users will not be able to enable or run Python scripts through Advanced Paste.</string>
|
||||
<string id="MwbClipboardSharingEnabled">Clipboard sharing enabled</string>
|
||||
<string id="MwbFileTransferEnabled">File transfer enabled</string>
|
||||
<string id="MwbUseOriginalUserInterface">Original user interface is available</string>
|
||||
|
||||
@@ -47,6 +47,8 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
|
||||
|
||||
public bool ShowCustomPreview => false;
|
||||
|
||||
public bool ShowAIPaste => true;
|
||||
|
||||
public bool CloseAfterLosingFocus => false;
|
||||
|
||||
public bool EnableClipboardPreview => true;
|
||||
@@ -57,22 +59,6 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
|
||||
|
||||
public PasteAIConfiguration PasteAIConfiguration => _configuration;
|
||||
|
||||
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => Array.Empty<AdvancedPastePythonScriptAction>();
|
||||
|
||||
public string PythonScriptsFolder => string.Empty;
|
||||
|
||||
public string PythonExecutablePath => string.Empty;
|
||||
|
||||
public bool PythonUseWsl => false;
|
||||
|
||||
public string PythonWslDistribution => string.Empty;
|
||||
|
||||
public int PythonScriptTimeoutSeconds => 30;
|
||||
|
||||
public bool IsPythonScriptsEnabled => true;
|
||||
|
||||
public IReadOnlyDictionary<string, string> TrustedScriptHashes => new Dictionary<string, string>();
|
||||
|
||||
public event EventHandler Changed;
|
||||
|
||||
public Task SetActiveAIProviderAsync(string providerId)
|
||||
@@ -81,8 +67,4 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void StoreTrustedScriptHash(string scriptPath, string hash)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
// 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.IO;
|
||||
using System.Linq;
|
||||
|
||||
using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Services.PythonScripts;
|
||||
using AdvancedPaste.UnitTests.Mocks;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AdvancedPaste.UnitTests.ServicesTests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class PythonScriptServiceTests
|
||||
{
|
||||
private PythonScriptService _service;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_service = new PythonScriptService(new IntegrationTestUserSettings());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_DetectsSimpleImports()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"# @advancedpaste:name test",
|
||||
"import requests",
|
||||
"import numpy",
|
||||
"import os",
|
||||
"import sys",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(2, result.Count); // requests + numpy; os and sys are stdlib
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "requests" && r.PipPackage == "requests"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "numpy" && r.PipPackage == "numpy"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_DetectsFromImports()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"from PIL import Image",
|
||||
"from markitdown import MarkItDown",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "PIL" && r.PipPackage == "Pillow"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "markitdown" && r.PipPackage == "markitdown"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_WellKnownMappings()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import cv2",
|
||||
"import win32clipboard",
|
||||
"import yaml",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(3, result.Count);
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "cv2" && r.PipPackage == "opencv-python"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "win32clipboard" && r.PipPackage == "pywin32"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "yaml" && r.PipPackage == "PyYAML"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_ExplicitRequirementsTakePrecedence()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import cv2",
|
||||
"import requests",
|
||||
};
|
||||
|
||||
var explicitReqs = new List<PythonRequirement>
|
||||
{
|
||||
new("cv2", "opencv-python-headless"),
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, explicitReqs);
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
|
||||
// cv2 should use the explicit pip package name, not the well-known mapping
|
||||
var cv2Req = result.First(r => r.ImportName == "cv2");
|
||||
Assert.AreEqual("opencv-python-headless", cv2Req.PipPackage);
|
||||
|
||||
// requests should be auto-detected
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_SkipsStdlib()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import os",
|
||||
"import sys",
|
||||
"import json",
|
||||
"import io",
|
||||
"import pathlib",
|
||||
"import tempfile",
|
||||
"import subprocess",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(0, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_SkipsComments()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"# import requests",
|
||||
"# from PIL import Image",
|
||||
"import json",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(0, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_HandlesMultipleImportsOnOneLine()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import requests, numpy, pandas",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(3, result.Count);
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "numpy"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "pandas"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MergeWithAutoDetectedImports_HandlesSubmoduleImport()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"import win32com.client",
|
||||
"from llama_cpp import Llama",
|
||||
};
|
||||
|
||||
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
|
||||
|
||||
Assert.AreEqual(2, result.Count);
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "win32com" && r.PipPackage == "pywin32"));
|
||||
Assert.IsTrue(result.Any(r => r.ImportName == "llama_cpp" && r.PipPackage == "llama-cpp-python"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_ModuleNotFoundError()
|
||||
{
|
||||
var stderr = """
|
||||
Traceback (most recent call last):
|
||||
File "C:\scripts\reverse.py", line 4, in <module>
|
||||
import win32clipboard
|
||||
ModuleNotFoundError: No module named 'win32clipboard'
|
||||
""";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("reverse.py"), $"Summary should mention the script: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 4"), $"Summary should mention the line: {summary}");
|
||||
Assert.IsTrue(summary.Contains("win32clipboard"), $"Summary should mention the module: {summary}");
|
||||
Assert.IsTrue(summary.Contains("pywin32"), $"Summary should suggest pip package: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(details));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_SyntaxError()
|
||||
{
|
||||
var stderr = """
|
||||
File "test.py", line 5
|
||||
def foo(
|
||||
^
|
||||
SyntaxError: unexpected EOF while parsing
|
||||
""";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 5"), $"Summary should mention the line: {summary}");
|
||||
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(details));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_SyntaxErrorWithColumn()
|
||||
{
|
||||
var stderr = " File \"script.py\", line 3\n x = (1 +\n ^\nSyntaxError: '(' was never closed\n";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("script.py"), $"Summary should mention the script: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 3"), $"Summary should mention the line: {summary}");
|
||||
Assert.IsTrue(summary.Contains("col"), $"Summary should mention the column: {summary}");
|
||||
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(details));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_GenericError()
|
||||
{
|
||||
var stderr = """
|
||||
Traceback (most recent call last):
|
||||
File "test.py", line 10, in <module>
|
||||
result = 1 / 0
|
||||
ZeroDivisionError: division by zero
|
||||
""";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 10"), $"Summary should mention the line: {summary}");
|
||||
Assert.IsTrue(summary.Contains("ZeroDivisionError"), $"Summary: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(details));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_NestedTraceback_ShowsLastFrame()
|
||||
{
|
||||
var stderr = """
|
||||
Traceback (most recent call last):
|
||||
File "main.py", line 5, in <module>
|
||||
helper()
|
||||
File "helper.py", line 12, in helper
|
||||
do_work()
|
||||
File "worker.py", line 8, in do_work
|
||||
raise RuntimeError("bad state")
|
||||
RuntimeError: bad state
|
||||
""";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("worker.py"), $"Summary should mention the last script in the chain: {summary}");
|
||||
Assert.IsTrue(summary.Contains("line 8"), $"Summary should mention the line of the last frame: {summary}");
|
||||
Assert.IsTrue(summary.Contains("bad state"), $"Summary should contain the error message: {summary}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_EmptyStderr()
|
||||
{
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(string.Empty);
|
||||
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(summary));
|
||||
Assert.AreEqual(string.Empty, details);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePythonError_NoTraceback_PlainStderr()
|
||||
{
|
||||
var stderr = "Something went wrong in the script\n";
|
||||
|
||||
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
|
||||
|
||||
// No File "..." reference, so no location — just the message
|
||||
Assert.IsTrue(summary.Contains("Something went wrong"), $"Summary: {summary}");
|
||||
Assert.IsFalse(summary.Contains("line"), $"Summary should not contain 'line' without a traceback: {summary}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExtractLastTracebackLocation_BasicTraceback()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"Traceback (most recent call last):",
|
||||
" File \"script.py\", line 10, in <module>",
|
||||
" result = 1 / 0",
|
||||
"ZeroDivisionError: division by zero",
|
||||
};
|
||||
|
||||
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
|
||||
|
||||
Assert.IsNotNull(location);
|
||||
Assert.AreEqual("script.py", location.Value.FileName);
|
||||
Assert.AreEqual(10, location.Value.Line);
|
||||
Assert.IsNull(location.Value.Column);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExtractLastTracebackLocation_WithCaret()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
" File \"test.py\", line 5",
|
||||
" def foo(",
|
||||
" ^",
|
||||
"SyntaxError: unexpected EOF while parsing",
|
||||
};
|
||||
|
||||
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
|
||||
|
||||
Assert.IsNotNull(location);
|
||||
Assert.AreEqual("test.py", location.Value.FileName);
|
||||
Assert.AreEqual(5, location.Value.Line);
|
||||
Assert.IsNotNull(location.Value.Column);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExtractLastTracebackLocation_FullPath_ReturnsBasename()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"Traceback (most recent call last):",
|
||||
" File \"C:\\Users\\user\\scripts\\my_script.py\", line 42, in <module>",
|
||||
" some_call()",
|
||||
"ValueError: invalid value",
|
||||
};
|
||||
|
||||
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
|
||||
|
||||
Assert.IsNotNull(location);
|
||||
Assert.AreEqual("my_script.py", location.Value.FileName);
|
||||
Assert.AreEqual(42, location.Value.Line);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExtractLastTracebackLocation_NoFileLine_ReturnsNull()
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"Some random error output",
|
||||
"No traceback here",
|
||||
};
|
||||
|
||||
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
|
||||
|
||||
Assert.IsNull(location);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePipInstallError_ExtractsErrorLine()
|
||||
{
|
||||
var stderr = """
|
||||
Collecting some-package
|
||||
Downloading some-package-1.0.tar.gz (15 kB)
|
||||
ERROR: Could not find a version that satisfies the requirement some-package (from versions: none)
|
||||
ERROR: No matching distribution found for some-package
|
||||
""";
|
||||
|
||||
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("No matching distribution"), $"Summary should contain the last ERROR line: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePipInstallError_NoErrorPrefix_UsesLastLine()
|
||||
{
|
||||
var stderr = "permission denied: /usr/lib/python3/dist-packages\n";
|
||||
|
||||
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
|
||||
|
||||
Assert.IsTrue(summary.Contains("permission denied"), $"Summary: {summary}");
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePipInstallError_EmptyStderr()
|
||||
{
|
||||
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(string.Empty);
|
||||
|
||||
Assert.AreEqual("unknown error", summary);
|
||||
Assert.AreEqual(string.Empty, fullStderr);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Text()
|
||||
{
|
||||
// The new interface uses function names like advanced_paste_from_text_to_text(...)
|
||||
// to determine supported formats, not parameter signatures.
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_text(text):\n return text.upper()\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Html()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_html_to_text(html: str) -> str:\n return html\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Html, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Image()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_image_to_text(image_path):\n return 'desc'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Image, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Files()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_files_to_text(file_paths):\n return ''\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.File, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_Image()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_image(text):\n return '/path/img.png'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual("image", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_File()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_file(text):\n return '/path/out.txt'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual("file", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_Files()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_files_to_files(file_paths):\n return file_paths\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual("files", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_RejectsMultipleFunctions()
|
||||
{
|
||||
var scriptPath = CreateTempScript(
|
||||
"def advanced_paste_from_text_to_text(text):\n return text\n\n" +
|
||||
"def advanced_paste_from_html_to_text(html):\n return html\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNull(metadata);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_RejectsNoFunction()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def some_other_function(text):\n return text\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNull(metadata);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_RejectsOldFormatWithoutTo()
|
||||
{
|
||||
// Old format (advanced_paste_from_text without _to_) should be rejected.
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text(text):\n return text\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNull(metadata);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Audio()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_audio_to_text(audio_path):\n return 'transcribed'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Audio, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_FunctionNameDeterminesFormat_Video()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_video_to_text(video_path):\n return 'description'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Video, metadata.SupportedFormats);
|
||||
Assert.AreEqual("text", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_Audio()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_audio(text):\n return '/path/out.mp3'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
|
||||
Assert.AreEqual("audio", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ReadMetadata_OutputTypeHint_Video()
|
||||
{
|
||||
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_video(text):\n return '/path/out.mp4'\n");
|
||||
var metadata = _service.ReadMetadata(scriptPath);
|
||||
|
||||
Assert.IsNotNull(metadata);
|
||||
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
|
||||
Assert.AreEqual("video", metadata.OutputTypeHint);
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
|
||||
private static string CreateTempScript(string content)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test_script_{Guid.NewGuid():N}.py");
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -153,9 +153,5 @@
|
||||
<Content Include="Assets\AdvancedPaste\SemanticKernel.svg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
||||
<Content Include="Services\PythonScripts\_runner.py">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -14,7 +14,6 @@ using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Models;
|
||||
using AdvancedPaste.Services;
|
||||
using AdvancedPaste.Services.CustomActions;
|
||||
using AdvancedPaste.Services.PythonScripts;
|
||||
using AdvancedPaste.Settings;
|
||||
using AdvancedPaste.ViewModels;
|
||||
using ManagedCommon;
|
||||
@@ -84,8 +83,6 @@ namespace AdvancedPaste
|
||||
services.AddSingleton<IPasteAIProviderFactory, PasteAIProviderFactory>();
|
||||
services.AddSingleton<ICustomActionTransformService, CustomActionTransformService>();
|
||||
services.AddSingleton<IKernelService, AdvancedAIKernelService>();
|
||||
services.AddSingleton<IPythonScriptService, PythonScriptService>();
|
||||
services.AddSingleton<IPythonScriptTrustService, PythonScriptTrustService>();
|
||||
services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>();
|
||||
services.AddSingleton<OptionsViewModel>();
|
||||
}).Build();
|
||||
|
||||
@@ -70,12 +70,12 @@
|
||||
Spacing="2">
|
||||
<TextBlock
|
||||
Style="{StaticResource BodyTextBlockStyle}"
|
||||
Text="{x:Bind Header, Mode=OneWay}"
|
||||
Text="{x:Bind Header, Mode=OneTime}"
|
||||
TextWrapping="NoWrap" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Timestamp, Converter={StaticResource DateTimeToFriendlyStringConverter}, Mode=OneWay}" />
|
||||
Text="{x:Bind Timestamp, Converter={StaticResource DateTimeToFriendlyStringConverter}, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -755,7 +755,63 @@
|
||||
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
|
||||
</animations:Implicit.HideAnimations>
|
||||
</TextBlock>
|
||||
<!-- Error message grid moved to MainPage.xaml so it remains enabled when PromptBox is disabled -->
|
||||
<Grid
|
||||
x:Name="ErrorMessageGrid"
|
||||
x:Uid="ErrorMessageGrid"
|
||||
Grid.Row="1"
|
||||
Margin="8,8,0,0"
|
||||
ColumnSpacing="8"
|
||||
Visibility="Collapsed">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||
<ToolTipService.ToolTip>
|
||||
<ToolTip VerticalOffset="-105" Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<StackPanel
|
||||
MinWidth="300"
|
||||
HorizontalAlignment="Stretch"
|
||||
Orientation="Vertical">
|
||||
<TextBox
|
||||
x:Name="AIErrorMessage"
|
||||
x:Uid="AIErrorMessage"
|
||||
FontSize="12"
|
||||
IsReadOnly="True"
|
||||
Text="{x:Bind ViewModel.PasteActionError.Details, Mode=OneWay}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</ToolTip>
|
||||
</ToolTipService.ToolTip>
|
||||
<FontIcon
|
||||
Margin="0,3,3,0"
|
||||
VerticalAlignment="Top"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<TextBlock
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
<HyperlinkButton
|
||||
x:Uid="SettingsBtn"
|
||||
Grid.Column="1"
|
||||
Margin="0,-1,0,0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Command="{x:Bind ViewModel.OpenSettingsCommand}"
|
||||
FontSize="12" />
|
||||
<animations:Implicit.ShowAnimations>
|
||||
<animations:OpacityAnimation To="1.0" Duration="0:0:0.6" />
|
||||
</animations:Implicit.ShowAnimations>
|
||||
<animations:Implicit.HideAnimations>
|
||||
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
|
||||
</animations:Implicit.HideAnimations>
|
||||
</Grid>
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="DefaultState" />
|
||||
@@ -776,6 +832,7 @@
|
||||
<VisualState.Setters>
|
||||
<Setter Target="InputTxtBox.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
|
||||
<Setter Target="DisclaimerPresenter.Visibility" Value="Collapsed" />
|
||||
<Setter Target="ErrorMessageGrid.Visibility" Value="Visible" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
|
||||
@@ -43,8 +43,7 @@ namespace AdvancedPaste
|
||||
double GetHeight(int maxCustomActionCount) =>
|
||||
baseHeight +
|
||||
new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) +
|
||||
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0) +
|
||||
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.PythonScriptPasteFormats.Count);
|
||||
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0);
|
||||
|
||||
MinHeight = GetHeight(1);
|
||||
Height = GetHeight(5);
|
||||
@@ -60,7 +59,6 @@ namespace AdvancedPaste
|
||||
UpdateHeight();
|
||||
}
|
||||
};
|
||||
_optionsViewModel.PythonScriptPasteFormats.CollectionChanged += (_, _) => UpdateHeight();
|
||||
|
||||
AppWindow.SetIcon("Assets/AdvancedPaste/AdvancedPaste.ico");
|
||||
this.ExtendsContentIntoTitleBar = true;
|
||||
@@ -143,7 +141,11 @@ namespace AdvancedPaste
|
||||
internal void FinishLoading(bool success)
|
||||
{
|
||||
MainPage.CustomFormatTextBox.IsLoading(false);
|
||||
VisualStateManager.GoToState(MainPage.CustomFormatTextBox, "DefaultState", true);
|
||||
|
||||
if (success)
|
||||
{
|
||||
VisualStateManager.GoToState(MainPage.CustomFormatTextBox, "DefaultState", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,31 +29,31 @@
|
||||
Padding="-9,0,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
AutomationProperties.AcceleratorKey="{x:Bind ShortcutText, Mode=OneWay}"
|
||||
AutomationProperties.AcceleratorKey="{x:Bind ShortcutText, Mode=OneTime}"
|
||||
AutomationProperties.AutomationControlType="ListItem"
|
||||
AutomationProperties.FullDescription="{x:Bind ToolTip, Mode=OneWay}"
|
||||
AutomationProperties.HelpText="{x:Bind Name, Mode=OneWay}"
|
||||
AutomationProperties.Name="{x:Bind AccessibleName, Mode=OneWay}">
|
||||
AutomationProperties.FullDescription="{x:Bind ToolTip, Mode=OneTime}"
|
||||
AutomationProperties.HelpText="{x:Bind Name, Mode=OneTime}"
|
||||
AutomationProperties.Name="{x:Bind AccessibleName, Mode=OneTime}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="48" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock Text="{x:Bind ToolTip, Mode=OneWay}" />
|
||||
<TextBlock Text="{x:Bind ToolTip, Mode=OneTime}" />
|
||||
</ToolTipService.ToolTip>
|
||||
<FontIcon
|
||||
Margin="0,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="16"
|
||||
Glyph="{x:Bind IconGlyph, Mode=OneWay}" />
|
||||
Glyph="{x:Bind IconGlyph, Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
x:Phase="1"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Name, Mode=OneWay}" />
|
||||
Text="{x:Bind Name, Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Grid.Column="2"
|
||||
Margin="0,0,8,0"
|
||||
@@ -61,7 +61,7 @@
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ShortcutText, Mode=OneWay}" />
|
||||
Text="{x:Bind ShortcutText, Mode=OneTime}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</controls:PasteFormatTemplateSelector.ItemTemplate>
|
||||
@@ -83,13 +83,13 @@
|
||||
Margin="0,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="16"
|
||||
Glyph="{x:Bind IconGlyph, Mode=OneWay}" />
|
||||
Glyph="{x:Bind IconGlyph, Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
x:Phase="1"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Name, Mode=OneWay}" />
|
||||
Text="{x:Bind Name, Mode=OneTime}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</controls:PasteFormatTemplateSelector.ItemTemplateDisabled>
|
||||
@@ -144,7 +144,6 @@
|
||||
</Page.KeyboardAccelerators>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
@@ -199,7 +198,7 @@
|
||||
<ItemsView.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:ClipboardItem">
|
||||
<ItemContainer
|
||||
AutomationProperties.Name="{x:Bind Description, Mode=OneWay}"
|
||||
AutomationProperties.Name="{x:Bind Description, Mode=OneTime}"
|
||||
CornerRadius="16"
|
||||
ToolTipService.ToolTip="{x:Bind Content}">
|
||||
<Grid
|
||||
@@ -251,10 +250,11 @@
|
||||
Grid.Row="1"
|
||||
Margin="20,0,20,0"
|
||||
x:FieldModifier="public"
|
||||
IsEnabled="True"
|
||||
TabIndex="0">
|
||||
IsEnabled="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay}"
|
||||
TabIndex="0"
|
||||
Visibility="{x:Bind ViewModel.ShowAIPasteSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
|
||||
<controls:PromptBox.Footer>
|
||||
<StackPanel Orientation="Horizontal" Visibility="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock
|
||||
x:Uid="AIMistakeNote"
|
||||
Margin="0,0,2,0"
|
||||
@@ -300,70 +300,19 @@
|
||||
</StackPanel>
|
||||
</controls:PromptBox.Footer>
|
||||
</controls:PromptBox>
|
||||
<Grid
|
||||
x:Name="ErrorMessageGrid"
|
||||
Grid.Row="2"
|
||||
Margin="20,4,20,0"
|
||||
ColumnSpacing="8"
|
||||
Visibility="{x:Bind ViewModel.PasteActionError.HasText, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<FontIcon
|
||||
Grid.Column="0"
|
||||
Margin="0,3,3,0"
|
||||
VerticalAlignment="Top"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
MaxLines="2"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="Wrap" />
|
||||
<HyperlinkButton
|
||||
x:Name="ShowErrorDetailsBtn"
|
||||
x:Uid="ShowErrorDetailsBtn"
|
||||
Grid.Column="2"
|
||||
Margin="4,-1,0,0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Click="ShowErrorDetailsBtn_Click"
|
||||
FontSize="12"
|
||||
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<HyperlinkButton
|
||||
x:Uid="SettingsBtn"
|
||||
Grid.Column="3"
|
||||
Margin="0,-1,0,0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Command="{x:Bind ViewModel.OpenSettingsCommand}"
|
||||
FontSize="12" />
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="3">
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<Grid RowSpacing="4">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<ListView
|
||||
x:Name="PasteOptionsListView"
|
||||
Grid.Row="0"
|
||||
VerticalAlignment="Bottom"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="PasteFormat_ItemClick"
|
||||
ItemContainerTransitions="{x:Null}"
|
||||
@@ -393,27 +342,6 @@
|
||||
ScrollViewer.VerticalScrollMode="Disabled"
|
||||
SelectionMode="None"
|
||||
TabIndex="2" />
|
||||
|
||||
<Rectangle
|
||||
Grid.Row="3"
|
||||
Height="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
Fill="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||
Visibility="{x:Bind ViewModel.PythonScriptPasteFormats.Count, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" />
|
||||
|
||||
<ListView
|
||||
x:Name="PythonScriptsListView"
|
||||
Grid.Row="4"
|
||||
VerticalAlignment="Top"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="PasteFormat_ItemClick"
|
||||
ItemContainerTransitions="{x:Null}"
|
||||
ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}"
|
||||
ItemsSource="{x:Bind ViewModel.PythonScriptPasteFormats, Mode=OneWay}"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.VerticalScrollMode="Disabled"
|
||||
SelectionMode="None"
|
||||
TabIndex="3" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
@@ -208,43 +208,5 @@ namespace AdvancedPaste.Pages
|
||||
Clipboard.SetHistoryItemAsContent(item.Item);
|
||||
}
|
||||
}
|
||||
|
||||
private async void ShowErrorDetailsBtn_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var details = ViewModel.PasteActionError?.Details;
|
||||
if (string.IsNullOrEmpty(details))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scrollViewer = new ScrollViewer
|
||||
{
|
||||
MaxHeight = 400,
|
||||
MinWidth = 400,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
};
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = details,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
|
||||
FontSize = 12,
|
||||
IsTextSelectionEnabled = true,
|
||||
};
|
||||
|
||||
scrollViewer.Content = textBlock;
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogTitle"),
|
||||
CloseButtonText = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogClose"),
|
||||
Content = scrollViewer,
|
||||
XamlRoot = this.XamlRoot,
|
||||
};
|
||||
|
||||
await dialog.ShowAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public bool ShowCustomPreview { get; }
|
||||
|
||||
public bool ShowAIPaste { get; }
|
||||
|
||||
public bool CloseAfterLosingFocus { get; }
|
||||
|
||||
public bool EnableClipboardPreview { get; }
|
||||
@@ -27,26 +29,8 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public PasteAIConfiguration PasteAIConfiguration { get; }
|
||||
|
||||
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions { get; }
|
||||
|
||||
public bool IsPythonScriptsEnabled { get; }
|
||||
|
||||
public string PythonScriptsFolder { get; }
|
||||
|
||||
public string PythonExecutablePath { get; }
|
||||
|
||||
public bool PythonUseWsl { get; }
|
||||
|
||||
public string PythonWslDistribution { get; }
|
||||
|
||||
public int PythonScriptTimeoutSeconds { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; }
|
||||
|
||||
public event EventHandler Changed;
|
||||
|
||||
Task SetActiveAIProviderAsync(string providerId);
|
||||
|
||||
void StoreTrustedScriptHash(string scriptPath, string hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -26,10 +25,6 @@ namespace AdvancedPaste.Settings
|
||||
private readonly Lock _loadingSettingsLock = new();
|
||||
private readonly List<PasteFormats> _additionalActions;
|
||||
private readonly List<AdvancedPasteCustomAction> _customActions;
|
||||
private readonly List<AdvancedPastePythonScriptAction> _pythonScriptActions;
|
||||
private FileSystemWatcher _scriptFolderWatcher;
|
||||
private CancellationTokenSource _scriptFolderDebounce;
|
||||
private string _watchedScriptsFolder = string.Empty;
|
||||
|
||||
private const string AdvancedPasteModuleName = "AdvancedPaste";
|
||||
private const int MaxNumberOfRetry = 5;
|
||||
@@ -43,6 +38,8 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public bool ShowCustomPreview { get; private set; }
|
||||
|
||||
public bool ShowAIPaste { get; private set; }
|
||||
|
||||
public bool CloseAfterLosingFocus { get; private set; }
|
||||
|
||||
public bool EnableClipboardPreview { get; private set; }
|
||||
@@ -53,39 +50,18 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public PasteAIConfiguration PasteAIConfiguration { get; private set; }
|
||||
|
||||
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => _pythonScriptActions;
|
||||
|
||||
public string PythonScriptsFolder { get; private set; }
|
||||
|
||||
public bool IsPythonScriptsEnabled { get; private set; }
|
||||
|
||||
public string PythonExecutablePath { get; private set; }
|
||||
|
||||
public bool PythonUseWsl { get; private set; }
|
||||
|
||||
public string PythonWslDistribution { get; private set; } = string.Empty;
|
||||
|
||||
public int PythonScriptTimeoutSeconds { get; private set; } = 30;
|
||||
|
||||
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; private set; } = new Dictionary<string, string>();
|
||||
|
||||
public UserSettings(IFileSystem fileSystem)
|
||||
{
|
||||
_settingsUtils = new SettingsUtils(fileSystem);
|
||||
|
||||
IsAIEnabled = false;
|
||||
ShowCustomPreview = true;
|
||||
ShowAIPaste = true;
|
||||
CloseAfterLosingFocus = false;
|
||||
EnableClipboardPreview = true;
|
||||
PasteAIConfiguration = new PasteAIConfiguration();
|
||||
PythonScriptsFolder = GetDefaultScriptsFolder();
|
||||
PythonExecutablePath = string.Empty;
|
||||
PythonUseWsl = false;
|
||||
PythonWslDistribution = string.Empty;
|
||||
PythonScriptTimeoutSeconds = 30;
|
||||
_additionalActions = [];
|
||||
_customActions = [];
|
||||
_pythonScriptActions = [];
|
||||
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
|
||||
|
||||
LoadSettingsFromJson();
|
||||
@@ -93,14 +69,6 @@ namespace AdvancedPaste.Settings
|
||||
_watcher = Helper.GetFileWatcher(AdvancedPasteModuleName, "settings.json", OnSettingsFileChanged, fileSystem);
|
||||
}
|
||||
|
||||
private static string GetDefaultScriptsFolder() =>
|
||||
System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"AdvancedPaste",
|
||||
"Scripts");
|
||||
|
||||
private void OnSettingsFileChanged()
|
||||
{
|
||||
lock (_loadingSettingsLock)
|
||||
@@ -144,6 +112,7 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
IsAIEnabled = properties.IsAIEnabled;
|
||||
ShowCustomPreview = properties.ShowCustomPreview;
|
||||
ShowAIPaste = properties.ShowAIPaste;
|
||||
CloseAfterLosingFocus = properties.CloseAfterLosingFocus;
|
||||
EnableClipboardPreview = properties.EnableClipboardPreview;
|
||||
PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration();
|
||||
@@ -166,49 +135,6 @@ namespace AdvancedPaste.Settings
|
||||
_customActions.Clear();
|
||||
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
|
||||
|
||||
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
|
||||
pythonScripts.MigrateLegacyIfNeeded();
|
||||
|
||||
var mode = pythonScripts.Mode ?? "disabled";
|
||||
|
||||
// Enforce GPO: if Python scripts are disallowed by policy, force disabled.
|
||||
if (PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPastePythonScriptsValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
|
||||
{
|
||||
mode = "disabled";
|
||||
}
|
||||
|
||||
IsPythonScriptsEnabled = !string.Equals(mode, "disabled", StringComparison.OrdinalIgnoreCase);
|
||||
PythonUseWsl = string.Equals(mode, "wsl", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (PythonUseWsl)
|
||||
{
|
||||
var wslSettings = pythonScripts.WslSettings ?? new PythonScriptWslSettings();
|
||||
PythonScriptsFolder = string.IsNullOrWhiteSpace(wslSettings.ScriptsFolder)
|
||||
? GetDefaultScriptsFolder()
|
||||
: wslSettings.ScriptsFolder;
|
||||
PythonExecutablePath = string.Empty;
|
||||
PythonWslDistribution = wslSettings.Distribution ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
var winSettings = pythonScripts.WindowsSettings ?? new PythonScriptWindowsSettings();
|
||||
PythonScriptsFolder = string.IsNullOrWhiteSpace(winSettings.ScriptsFolder)
|
||||
? GetDefaultScriptsFolder()
|
||||
: winSettings.ScriptsFolder;
|
||||
PythonExecutablePath = winSettings.PythonExecutablePath ?? string.Empty;
|
||||
PythonWslDistribution = string.Empty;
|
||||
}
|
||||
|
||||
PythonScriptTimeoutSeconds = pythonScripts.TimeoutSeconds > 0 ? pythonScripts.TimeoutSeconds : 30;
|
||||
TrustedScriptHashes = new Dictionary<string, string>(
|
||||
pythonScripts.TrustedScriptHashes ?? new Dictionary<string, string>(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_pythonScriptActions.Clear();
|
||||
_pythonScriptActions.AddRange(pythonScripts.Value);
|
||||
|
||||
UpdateScriptFolderWatcher(PythonScriptsFolder);
|
||||
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -373,103 +299,6 @@ namespace AdvancedPaste.Settings
|
||||
return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private void UpdateScriptFolderWatcher(string folderPath)
|
||||
{
|
||||
if (string.Equals(_watchedScriptsFolder, folderPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_scriptFolderWatcher?.Dispose();
|
||||
_scriptFolderWatcher = null;
|
||||
_watchedScriptsFolder = folderPath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(folderPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!System.IO.Directory.Exists(folderPath))
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(folderPath);
|
||||
}
|
||||
|
||||
_scriptFolderWatcher = new FileSystemWatcher(folderPath, "*.py")
|
||||
{
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime,
|
||||
EnableRaisingEvents = true,
|
||||
IncludeSubdirectories = false,
|
||||
};
|
||||
|
||||
_scriptFolderWatcher.Changed += OnScriptFolderChanged;
|
||||
_scriptFolderWatcher.Created += OnScriptFolderChanged;
|
||||
_scriptFolderWatcher.Deleted += OnScriptFolderChanged;
|
||||
_scriptFolderWatcher.Renamed += OnScriptFolderChanged;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to set up script folder watcher for {folderPath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnScriptFolderChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
lock (_loadingSettingsLock)
|
||||
{
|
||||
_scriptFolderDebounce?.Cancel();
|
||||
_scriptFolderDebounce?.Dispose();
|
||||
_scriptFolderDebounce = new CancellationTokenSource();
|
||||
|
||||
var token = _scriptFolderDebounce.Token;
|
||||
Task.Delay(TimeSpan.FromMilliseconds(500), token)
|
||||
.ContinueWith(
|
||||
_ =>
|
||||
{
|
||||
Task.Factory
|
||||
.StartNew(
|
||||
() => Changed?.Invoke(this, EventArgs.Empty),
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.None,
|
||||
_taskScheduler);
|
||||
},
|
||||
token,
|
||||
TaskContinuationOptions.NotOnCanceled,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
public void StoreTrustedScriptHash(string scriptPath, string hash)
|
||||
{
|
||||
lock (_loadingSettingsLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<AdvancedPasteSettings>(AdvancedPasteModuleName);
|
||||
if (settings?.Properties?.PythonScripts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
settings.Properties.PythonScripts.TrustedScriptHashes ??= new Dictionary<string, string>();
|
||||
settings.Properties.PythonScripts.TrustedScriptHashes[scriptPath] = hash;
|
||||
settings.Save(_settingsUtils);
|
||||
|
||||
// Update in-memory cache.
|
||||
var updated = new Dictionary<string, string>(TrustedScriptHashes, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[scriptPath] = hash,
|
||||
};
|
||||
TrustedScriptHashes = updated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to store trusted script hash", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetActiveAIProviderAsync(string providerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
@@ -562,8 +391,6 @@ namespace AdvancedPaste.Settings
|
||||
if (disposing)
|
||||
{
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_scriptFolderDebounce?.Dispose();
|
||||
_scriptFolderWatcher?.Dispose();
|
||||
_watcher?.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,14 +40,6 @@ public sealed class PasteFormat
|
||||
IsSavedQuery = isSavedQuery,
|
||||
};
|
||||
|
||||
public static PasteFormat CreatePythonScriptFormat(string name, string scriptPath, ClipboardFormat availableFormats) =>
|
||||
new(PasteFormats.PythonScript, availableFormats, isAIServiceEnabled: false)
|
||||
{
|
||||
Name = name,
|
||||
Prompt = scriptPath,
|
||||
IsSavedQuery = true,
|
||||
};
|
||||
|
||||
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
|
||||
|
||||
public string IconGlyph => Metadata.IconGlyph;
|
||||
|
||||
@@ -122,12 +122,4 @@ public enum PasteFormats
|
||||
KernelFunctionDescription = "Takes user instructions and applies them to the current clipboard content (text or image). Use this function for image analysis, description, or transformation tasks beyond simple OCR.",
|
||||
RequiresPrompt = true)]
|
||||
CustomTextTransformation,
|
||||
|
||||
[PasteFormatMetadata(
|
||||
IsCoreAction = false,
|
||||
IconGlyph = "\uE943",
|
||||
RequiresAIService = false,
|
||||
CanPreview = true,
|
||||
SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Image | ClipboardFormat.Audio | ClipboardFormat.Video | ClipboardFormat.File)]
|
||||
PythonScript,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 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.
|
||||
|
||||
@@ -9,23 +9,15 @@ using System.Threading.Tasks;
|
||||
using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Models;
|
||||
using AdvancedPaste.Services.CustomActions;
|
||||
using AdvancedPaste.Services.PythonScripts;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
|
||||
namespace AdvancedPaste.Services;
|
||||
|
||||
public sealed class PasteFormatExecutor(
|
||||
IKernelService kernelService,
|
||||
ICustomActionTransformService customActionTransformService,
|
||||
IPythonScriptService pythonScriptService,
|
||||
IPythonScriptTrustService pythonScriptTrustService) : IPasteFormatExecutor
|
||||
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor
|
||||
{
|
||||
private readonly IKernelService _kernelService = kernelService;
|
||||
private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService;
|
||||
private readonly IPythonScriptService _pythonScriptService = pythonScriptService;
|
||||
private readonly IPythonScriptTrustService _pythonScriptTrustService = pythonScriptTrustService;
|
||||
|
||||
public async Task<DataPackage> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress<double> progress)
|
||||
{
|
||||
@@ -40,15 +32,6 @@ public sealed class PasteFormatExecutor(
|
||||
|
||||
var clipboardData = Clipboard.GetContent();
|
||||
|
||||
// PythonScript must NOT run inside Task.Run: the trust confirmation (ContentDialog)
|
||||
// requires the UI (XAML) thread and will throw if called from a thread-pool thread.
|
||||
// Python script execution is fully async (process.WaitForExitAsync), so it is safe
|
||||
// to await it directly without wrapping in Task.Run.
|
||||
if (format == PasteFormats.PythonScript)
|
||||
{
|
||||
return await ExecutePythonScriptAsync(pasteFormat.Prompt, clipboardData, cancellationToken, progress);
|
||||
}
|
||||
|
||||
// Run on thread-pool; although we use Async routines consistently, some actions still occasionally take a long time without yielding.
|
||||
return await Task.Run(async () =>
|
||||
pasteFormat.Format switch
|
||||
@@ -59,111 +42,6 @@ public sealed class PasteFormatExecutor(
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<DataPackage> ExecutePythonScriptAsync(
|
||||
string scriptPath,
|
||||
DataPackageView clipboardData,
|
||||
CancellationToken cancellationToken,
|
||||
IProgress<double> progress)
|
||||
{
|
||||
// Security: ensure the script is trusted before executing.
|
||||
if (!_pythonScriptTrustService.IsTrusted(scriptPath))
|
||||
{
|
||||
string hash;
|
||||
try
|
||||
{
|
||||
hash = _pythonScriptTrustService.ComputeHash(scriptPath);
|
||||
}
|
||||
catch (System.IO.FileNotFoundException)
|
||||
{
|
||||
throw new PasteActionException(
|
||||
string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptNotFound"), scriptPath),
|
||||
new System.IO.FileNotFoundException(null, scriptPath));
|
||||
}
|
||||
|
||||
var approved = await _pythonScriptTrustService.RequestTrustAsync(scriptPath, hash);
|
||||
|
||||
if (!approved)
|
||||
{
|
||||
throw new OperationCanceledException("User declined to trust the Python script.");
|
||||
}
|
||||
|
||||
_pythonScriptTrustService.StoreTrust(scriptPath, hash);
|
||||
}
|
||||
|
||||
var metadata = _pythonScriptService.ReadMetadata(scriptPath);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Script '{scriptPath}' does not define a valid advanced_paste_from_*_to_*() function.");
|
||||
}
|
||||
|
||||
// Pre-flight: check for missing packages and offer to install them.
|
||||
var missingPackages = await _pythonScriptService.GetMissingRequirementsAsync(metadata, cancellationToken);
|
||||
if (missingPackages.Count > 0)
|
||||
{
|
||||
var approved = await _pythonScriptTrustService.RequestInstallAsync(metadata.Name, missingPackages);
|
||||
if (!approved)
|
||||
{
|
||||
throw new OperationCanceledException("User declined to install missing Python packages.");
|
||||
}
|
||||
|
||||
await _pythonScriptService.InstallRequirementsAsync(missingPackages, metadata.Platform, cancellationToken);
|
||||
}
|
||||
|
||||
var detectedFormat = await clipboardData.GetAvailableFormatsAsync();
|
||||
|
||||
// V2 interface: script defines advanced_paste_from_*_to_*() — use unified runner.
|
||||
if (metadata.IsV2)
|
||||
{
|
||||
return await _pythonScriptService.ExecuteScriptAsync(scriptPath, metadata.Platform, clipboardData, detectedFormat, cancellationToken, progress);
|
||||
}
|
||||
|
||||
// Legacy paths for backward compatibility.
|
||||
if (string.Equals(metadata.Platform, "linux", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await _pythonScriptService.ExecuteWslScriptAsync(scriptPath, clipboardData, detectedFormat, cancellationToken, progress);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Windows mode: script modifies the clipboard in-process; we return the updated clipboard.
|
||||
await _pythonScriptService.ExecuteWindowsScriptAsync(scriptPath, detectedFormat, cancellationToken, progress);
|
||||
|
||||
// Re-read clipboard after script has run.
|
||||
return Clipboard.GetContent() is { } updatedView
|
||||
? await DataPackageFromViewAsync(updatedView)
|
||||
: new DataPackage();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<DataPackage> DataPackageFromViewAsync(DataPackageView view)
|
||||
{
|
||||
var pkg = new DataPackage();
|
||||
|
||||
if (view.Contains(StandardDataFormats.Text))
|
||||
{
|
||||
pkg.SetText(await view.GetTextAsync());
|
||||
}
|
||||
|
||||
if (view.Contains(StandardDataFormats.Html))
|
||||
{
|
||||
pkg.SetHtmlFormat(await view.GetHtmlFormatAsync());
|
||||
}
|
||||
|
||||
if (view.Contains(StandardDataFormats.StorageItems))
|
||||
{
|
||||
var items = await view.GetStorageItemsAsync();
|
||||
pkg.SetStorageItems(items);
|
||||
}
|
||||
|
||||
if (view.Contains(StandardDataFormats.Bitmap))
|
||||
{
|
||||
var bitmap = await view.GetBitmapAsync();
|
||||
pkg.SetBitmap(bitmap);
|
||||
}
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
private static void WriteTelemetry(PasteFormats format, PasteActionSource source)
|
||||
{
|
||||
switch (source)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// 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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using AdvancedPaste.Models;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
|
||||
namespace AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
public interface IPythonScriptService
|
||||
{
|
||||
/// <summary>
|
||||
/// V2 unified execution: C# reads the clipboard, pipes data as JSON to the runner,
|
||||
/// and receives a DataPackage from JSON stdout. Works on both Windows and WSL
|
||||
/// depending on the specified platform.
|
||||
/// </summary>
|
||||
Task<DataPackage> ExecuteScriptAsync(string scriptPath, string platform, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
|
||||
|
||||
/// <summary>
|
||||
/// Legacy Windows mode: the script directly manipulates the clipboard. C# waits for the process to exit.
|
||||
/// Kept for backward compatibility with scripts that use win32clipboard directly.
|
||||
/// </summary>
|
||||
Task ExecuteWindowsScriptAsync(string scriptPath, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
|
||||
|
||||
/// <summary>
|
||||
/// Legacy WSL mode: C# passes data via JSON stdin, receives a DataPackage from JSON stdout.
|
||||
/// Kept for backward compatibility with scripts that use json.load(sys.stdin) directly.
|
||||
/// </summary>
|
||||
Task<DataPackage> ExecuteWslScriptAsync(string scriptPath, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the @advancedpaste: header comments from a Python script file.
|
||||
/// </summary>
|
||||
PythonScriptMetadata ReadMetadata(string scriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Discovers all .py scripts in <paramref name="folderPath"/> and returns their metadata.
|
||||
/// </summary>
|
||||
IReadOnlyList<PythonScriptMetadata> DiscoverScripts(string folderPath);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the Python executable to use. Returns null if none is found.
|
||||
/// </summary>
|
||||
string TryFindPythonExecutable(string overridePath = null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if wsl.exe is available on this machine.
|
||||
/// </summary>
|
||||
bool IsWslAvailable();
|
||||
|
||||
/// <summary>
|
||||
/// Checks which of the declared requirements are not yet importable.
|
||||
/// Returns an empty list if all packages are installed.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PythonRequirement>> GetMissingRequirementsAsync(
|
||||
PythonScriptMetadata metadata,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Installs the given packages via pip / pip3.
|
||||
/// </summary>
|
||||
Task InstallRequirementsAsync(
|
||||
IReadOnlyList<PythonRequirement> requirements,
|
||||
string platform,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// 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 System.Threading.Tasks;
|
||||
|
||||
namespace AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
public interface IPythonScriptTrustService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the script at <paramref name="scriptPath"/> is currently trusted (hash matches stored value).
|
||||
/// </summary>
|
||||
bool IsTrusted(string scriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Shows a UI confirmation dialog for the script. Returns true if the user approved execution.
|
||||
/// </summary>
|
||||
Task<bool> RequestTrustAsync(string scriptPath, string hash);
|
||||
|
||||
/// <summary>
|
||||
/// Persists the trust entry for <paramref name="scriptPath"/> with the given <paramref name="hash"/>.
|
||||
/// </summary>
|
||||
void StoreTrust(string scriptPath, string hash);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA-256 hash of the script file and returns the hex string.
|
||||
/// </summary>
|
||||
string ComputeHash(string scriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Shows a confirmation dialog listing the missing packages and asking the user
|
||||
/// whether to install them. Returns true if the user approved installation.
|
||||
/// </summary>
|
||||
Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// 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 AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single Python package requirement declared via
|
||||
/// <c># @advancedpaste:requires import_name=pip_package</c>.
|
||||
/// </summary>
|
||||
/// <param name="ImportName">The Python import name used in the script (e.g. "cv2").</param>
|
||||
/// <param name="PipPackage">The pip install name (e.g. "opencv-python-headless"). Equals <see cref="ImportName"/> when not explicitly specified.</param>
|
||||
public sealed record PythonRequirement(string ImportName, string PipPackage);
|
||||
@@ -1,21 +0,0 @@
|
||||
// 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 AdvancedPaste.Models;
|
||||
|
||||
namespace AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
public sealed record PythonScriptMetadata(
|
||||
string ScriptPath,
|
||||
string Name,
|
||||
string Description,
|
||||
ClipboardFormat SupportedFormats,
|
||||
string Platform,
|
||||
string Version,
|
||||
bool IsEnabled,
|
||||
IReadOnlyList<PythonRequirement> Requirements,
|
||||
bool IsV2 = false,
|
||||
string OutputTypeHint = null);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,127 +0,0 @@
|
||||
// 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.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Settings;
|
||||
using ManagedCommon;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace AdvancedPaste.Services.PythonScripts;
|
||||
|
||||
public sealed class PythonScriptTrustService(IUserSettings userSettings) : IPythonScriptTrustService
|
||||
{
|
||||
private readonly IUserSettings _userSettings = userSettings;
|
||||
|
||||
public bool IsTrusted(string scriptPath)
|
||||
{
|
||||
var hashes = _userSettings.TrustedScriptHashes;
|
||||
if (hashes is null || !hashes.TryGetValue(scriptPath, out var storedHash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var currentHash = ComputeHash(scriptPath);
|
||||
return string.Equals(currentHash, storedHash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to compute hash for {scriptPath}", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RequestTrustAsync(string scriptPath, string hash)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = resourceLoader.GetString("PythonScriptTrustTitle"),
|
||||
Content = string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
resourceLoader.GetString("PythonScriptTrustContent"),
|
||||
scriptPath,
|
||||
hash),
|
||||
PrimaryButtonText = resourceLoader.GetString("PythonScriptTrustConfirm"),
|
||||
CloseButtonText = resourceLoader.GetString("PythonScriptTrustCancel"),
|
||||
};
|
||||
|
||||
// XamlRoot must be set for ContentDialog to function.
|
||||
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
|
||||
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
|
||||
{
|
||||
dialog.XamlRoot = xamlRoot;
|
||||
}
|
||||
|
||||
var result = await dialog.ShowAsync();
|
||||
return result == ContentDialogResult.Primary;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to show trust dialog", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void StoreTrust(string scriptPath, string hash)
|
||||
{
|
||||
_userSettings.StoreTrustedScriptHash(scriptPath, hash);
|
||||
}
|
||||
|
||||
public string ComputeHash(string scriptPath)
|
||||
{
|
||||
using var stream = File.OpenRead(scriptPath);
|
||||
var hashBytes = SHA256.HashData(stream);
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
public async Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
var packageList = string.Join("\n", missingPackages.Select(r =>
|
||||
string.Equals(r.ImportName, r.PipPackage, StringComparison.Ordinal)
|
||||
? $" • {r.PipPackage}"
|
||||
: $" • {r.PipPackage} (import: {r.ImportName})"));
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = resourceLoader.GetString("PythonPackageInstallTitle"),
|
||||
Content = string.Format(
|
||||
System.Globalization.CultureInfo.CurrentCulture,
|
||||
resourceLoader.GetString("PythonPackageInstallContent"),
|
||||
scriptName,
|
||||
packageList),
|
||||
PrimaryButtonText = resourceLoader.GetString("PythonPackageInstallConfirm"),
|
||||
CloseButtonText = resourceLoader.GetString("PythonPackageInstallCancel"),
|
||||
};
|
||||
|
||||
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
|
||||
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
|
||||
{
|
||||
dialog.XamlRoot = xamlRoot;
|
||||
}
|
||||
|
||||
var result = await dialog.ShowAsync();
|
||||
return result == ContentDialogResult.Primary;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to show package install dialog", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Advanced Paste – Script Runner (V3 Named Function Interface)
|
||||
|
||||
This runner is shipped with PowerToys and is NOT user-editable.
|
||||
It loads a user script, discovers the single advanced_paste_from_<input>_to_<output>
|
||||
function by name convention, calls it with the current clipboard data, and formats
|
||||
the return value into JSON on stdout.
|
||||
|
||||
Each script must define exactly one function matching the pattern:
|
||||
def advanced_paste_from_<input>_to_<output>(<param>)
|
||||
|
||||
Supported input types:
|
||||
- text, html, image, audio, video, files
|
||||
|
||||
Required output types (declared via _to_ suffix):
|
||||
- text, html, image, file, files
|
||||
|
||||
Examples:
|
||||
- advanced_paste_from_text_to_text(text: str) → output is text
|
||||
- advanced_paste_from_text_to_image(text: str) → output is image
|
||||
- advanced_paste_from_image_to_text(image_path) → output is text
|
||||
- advanced_paste_from_files_to_text(file_paths) → output is text
|
||||
|
||||
Protocol:
|
||||
- Input: JSON on stdin (clipboard data from C#)
|
||||
- Output: JSON on stdout (result for C# to set on clipboard)
|
||||
- Errors: stderr (displayed to user on failure)
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _apply_output_hint(result, hint: str) -> dict:
|
||||
"""
|
||||
Force the output to the type specified by the _to_ suffix in the function name.
|
||||
Converts the return value to match the hinted type.
|
||||
"""
|
||||
if result is None:
|
||||
if hint == "text":
|
||||
return {"result_type": "text", "text": ""}
|
||||
elif hint == "html":
|
||||
return {"result_type": "html", "html": ""}
|
||||
elif hint == "image":
|
||||
return {"result_type": "image", "image_path": ""}
|
||||
elif hint == "audio":
|
||||
return {"result_type": "audio", "audio_path": ""}
|
||||
elif hint == "video":
|
||||
return {"result_type": "video", "video_path": ""}
|
||||
elif hint in ("file", "files"):
|
||||
return {"result_type": hint, "file_paths": []}
|
||||
|
||||
if hint == "text":
|
||||
return {"result_type": "text", "text": str(result) if not isinstance(result, str) else result}
|
||||
elif hint == "html":
|
||||
return {"result_type": "html", "html": str(result) if not isinstance(result, str) else result}
|
||||
elif hint == "image":
|
||||
path = str(result)
|
||||
return {"result_type": "image", "image_path": path}
|
||||
elif hint == "audio":
|
||||
path = str(result)
|
||||
return {"result_type": "audio", "audio_path": path}
|
||||
elif hint == "video":
|
||||
path = str(result)
|
||||
return {"result_type": "video", "video_path": path}
|
||||
elif hint == "file":
|
||||
if isinstance(result, (list, tuple)):
|
||||
paths = [str(p) for p in result]
|
||||
else:
|
||||
paths = [str(result)]
|
||||
return {"result_type": "file", "file_paths": paths}
|
||||
elif hint == "files":
|
||||
if isinstance(result, (list, tuple)):
|
||||
paths = [str(p) for p in result]
|
||||
else:
|
||||
paths = [str(result)]
|
||||
return {"result_type": "files", "file_paths": paths}
|
||||
|
||||
# Fallback (shouldn't happen with valid hints)
|
||||
return {"result_type": "text", "text": str(result)}
|
||||
|
||||
# Pattern matching advanced_paste_from_<input>_to_<output> function names.
|
||||
_AP_FUNCTION_PATTERN = re.compile(
|
||||
r"^advanced_paste_from_(text|html|image|audio|video|files)_to_(text|html|image|audio|video|file|files)$"
|
||||
)
|
||||
|
||||
|
||||
def _load_user_module(script_path: str):
|
||||
"""Dynamically load the user script as a Python module."""
|
||||
spec = importlib.util.spec_from_file_location("_user_script", script_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Cannot load script: {script_path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Add the script's directory to sys.path so relative imports/helpers work.
|
||||
script_dir = os.path.dirname(os.path.abspath(script_path))
|
||||
if script_dir not in sys.path:
|
||||
sys.path.insert(0, script_dir)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _discover_ap_function(module) -> tuple:
|
||||
"""
|
||||
Discover the single advanced_paste_from_<input>_to_<output> function in the module.
|
||||
Returns a tuple (input_type, output_type, function) or None if not found.
|
||||
Exits with error if multiple functions are defined.
|
||||
"""
|
||||
matches = []
|
||||
for name in dir(module):
|
||||
match = _AP_FUNCTION_PATTERN.match(name)
|
||||
if match:
|
||||
fn = getattr(module, name)
|
||||
if callable(fn):
|
||||
input_type = match.group(1)
|
||||
output_type = match.group(2)
|
||||
matches.append((input_type, output_type, fn))
|
||||
|
||||
if len(matches) == 0:
|
||||
return None
|
||||
if len(matches) > 1:
|
||||
names = [f"advanced_paste_from_{m[0]}_to_{m[1]}" for m in matches]
|
||||
print(
|
||||
f"Error: script defines multiple advanced_paste_from_*_to_* functions "
|
||||
f"({', '.join(names)}). Only one is allowed per script.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _format_output(result, output_type: str) -> dict:
|
||||
"""
|
||||
Format the return value according to the declared output type from the function name.
|
||||
The output_type comes from the _to_ suffix and is always provided.
|
||||
"""
|
||||
if result is None:
|
||||
if output_type in ("file", "files"):
|
||||
return {"result_type": output_type, "file_paths": []}
|
||||
elif output_type == "image":
|
||||
return {"result_type": "image", "image_path": ""}
|
||||
elif output_type == "audio":
|
||||
return {"result_type": "audio", "audio_path": ""}
|
||||
elif output_type == "video":
|
||||
return {"result_type": "video", "video_path": ""}
|
||||
elif output_type == "html":
|
||||
return {"result_type": "html", "html": ""}
|
||||
return {"result_type": "text", "text": ""}
|
||||
|
||||
return _apply_output_hint(result, output_type)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: _runner.py <script_path>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
script_path = sys.argv[1]
|
||||
|
||||
if not os.path.isfile(script_path):
|
||||
print(f"Error: script not found: {script_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Read input payload from stdin.
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: invalid JSON input: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load the user script.
|
||||
module = _load_user_module(script_path)
|
||||
|
||||
# Discover the single advanced_paste_from_* function.
|
||||
ap_result = _discover_ap_function(module)
|
||||
|
||||
if ap_result is None:
|
||||
print(
|
||||
f"Error: script '{os.path.basename(script_path)}' does not define an "
|
||||
f"advanced_paste_from_<input>_to_<output> function.\n"
|
||||
f"Example: def advanced_paste_from_text_to_text(text): return text.upper()",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
input_type, output_type, fn = ap_result
|
||||
|
||||
# Determine the input data key for this function's input type.
|
||||
input_map = {
|
||||
"text": "text",
|
||||
"html": "html",
|
||||
"image": "image_path",
|
||||
"audio": "audio_path",
|
||||
"video": "video_path",
|
||||
"files": "file_paths",
|
||||
}
|
||||
|
||||
key = input_map.get(input_type, input_type)
|
||||
input_value = data.get(key)
|
||||
|
||||
# Expose work_dir as environment variable so scripts can write output files
|
||||
# to a location accessible from both WSL and Windows (under /mnt/c/...).
|
||||
work_dir = data.get("work_dir", "")
|
||||
if work_dir:
|
||||
os.environ["ADVANCED_PASTE_WORK_DIR"] = work_dir
|
||||
|
||||
# Check if the clipboard has matching data for this script's input type.
|
||||
formats = data.get("format", ["text"])
|
||||
if isinstance(formats, str):
|
||||
formats = [formats]
|
||||
|
||||
# Normalize: treat "file" and "files" as equivalent so that
|
||||
# advanced_paste_from_files_to_* scripts match the C# ClipboardFormat.File flag.
|
||||
normalized_formats = set(formats)
|
||||
if "file" in normalized_formats:
|
||||
normalized_formats.add("files")
|
||||
if "files" in normalized_formats:
|
||||
normalized_formats.add("file")
|
||||
|
||||
if input_type not in normalized_formats:
|
||||
print(
|
||||
f"Error: script expects '{input_type}' input but clipboard has [{', '.join(formats)}].",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if input_value is None:
|
||||
print(
|
||||
f"Error: no data available for format '{input_type}' "
|
||||
f"(expected '{key}' in input payload).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Call the function.
|
||||
result = fn(input_value)
|
||||
output = _format_output(result, output_type)
|
||||
|
||||
# Output JSON result.
|
||||
json.dump(output, sys.stdout, ensure_ascii=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -212,10 +212,10 @@
|
||||
<value>Delete</value>
|
||||
</data>
|
||||
<data name="CustomFormatTextBox.PlaceholderText" xml:space="preserve">
|
||||
<value>Search or describe what format you want...</value>
|
||||
<value>Describe what format you want..</value>
|
||||
</data>
|
||||
<data name="InputTxtBoxTooltip.Text" xml:space="preserve">
|
||||
<value>Search or describe what format you want...</value>
|
||||
<value>Describe what format you want..</value>
|
||||
</data>
|
||||
<data name="LearnMoreLink.Text" xml:space="preserve">
|
||||
<value>Privacy</value>
|
||||
@@ -372,75 +372,4 @@
|
||||
<value>Unable to load Foundry Local model: {0}</value>
|
||||
<comment>{0} is the model identifier. Do not translate {0}.</comment>
|
||||
</data>
|
||||
<data name="PythonNotFound" xml:space="preserve">
|
||||
<value>Python was not found. Please install Python or configure the path in Settings.</value>
|
||||
</data>
|
||||
<data name="WslNotAvailable" xml:space="preserve">
|
||||
<value>WSL is not installed or not available. Cannot run Linux scripts.</value>
|
||||
</data>
|
||||
<data name="PythonScriptFailed" xml:space="preserve">
|
||||
<value>The Python script failed to execute.</value>
|
||||
</data>
|
||||
<data name="PythonScriptTimeout" xml:space="preserve">
|
||||
<value>Script execution timed out ({0} seconds).</value>
|
||||
<comment>{0} is the configured timeout in seconds. Do not translate {0}.</comment>
|
||||
</data>
|
||||
<data name="PythonScriptNotFound" xml:space="preserve">
|
||||
<value>Script file not found: {0}</value>
|
||||
<comment>{0} is the script file path. Do not translate {0}.</comment>
|
||||
</data>
|
||||
<data name="PythonScriptInvalidJson" xml:space="preserve">
|
||||
<value>The script output is not valid JSON.</value>
|
||||
</data>
|
||||
<data name="PythonScriptTrustTitle" xml:space="preserve">
|
||||
<value>Run Python Script?</value>
|
||||
</data>
|
||||
<data name="PythonScriptTrustContent" xml:space="preserve">
|
||||
<value>This script has not been verified. Running untrusted scripts can be a security risk. Do you want to run the following script?
|
||||
|
||||
{0}
|
||||
|
||||
SHA256: {1}</value>
|
||||
<comment>{0} is the script file path. {1} is the SHA-256 hash of the script. Do not translate {0} or {1}.</comment>
|
||||
</data>
|
||||
<data name="PythonScriptTrustConfirm" xml:space="preserve">
|
||||
<value>Run</value>
|
||||
</data>
|
||||
<data name="PythonScriptTrustCancel" xml:space="preserve">
|
||||
<value>Cancel</value>
|
||||
</data>
|
||||
<data name="PythonPackageInstallTitle" xml:space="preserve">
|
||||
<value>Install Missing Packages?</value>
|
||||
</data>
|
||||
<data name="PythonPackageInstallContent" xml:space="preserve">
|
||||
<value>The script "{0}" requires the following Python packages that are not installed:
|
||||
|
||||
{1}
|
||||
|
||||
Install them now?</value>
|
||||
<comment>{0} = script display name, {1} = bullet list of package names. Do not translate package names.</comment>
|
||||
</data>
|
||||
<data name="PythonPackageInstallConfirm" xml:space="preserve">
|
||||
<value>Install</value>
|
||||
</data>
|
||||
<data name="PythonPackageInstallCancel" xml:space="preserve">
|
||||
<value>Skip</value>
|
||||
</data>
|
||||
<data name="PythonPackageInstallFailed" xml:space="preserve">
|
||||
<value>Failed to install package(s) "{0}": {1}</value>
|
||||
<comment>{0} = pip package names, {1} = error detail. Do not translate package names.</comment>
|
||||
</data>
|
||||
<data name="PythonPackageInstallTimeout" xml:space="preserve">
|
||||
<value>Package installation for "{0}" timed out ({1} seconds).</value>
|
||||
<comment>{0} = pip package names, {1} = timeout in seconds. Do not translate {0} or {1}.</comment>
|
||||
</data>
|
||||
<data name="ShowErrorDetailsBtn.Content" xml:space="preserve">
|
||||
<value>Show details</value>
|
||||
</data>
|
||||
<data name="ErrorDetailsDialogTitle" xml:space="preserve">
|
||||
<value>Error Details</value>
|
||||
</data>
|
||||
<data name="ErrorDetailsDialogClose" xml:space="preserve">
|
||||
<value>Close</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -16,7 +16,6 @@ using System.Threading.Tasks;
|
||||
using AdvancedPaste.Helpers;
|
||||
using AdvancedPaste.Models;
|
||||
using AdvancedPaste.Services;
|
||||
using AdvancedPaste.Services.PythonScripts;
|
||||
using AdvancedPaste.Settings;
|
||||
using Common.UI;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -42,7 +41,6 @@ namespace AdvancedPaste.ViewModels
|
||||
private readonly IUserSettings _userSettings;
|
||||
private readonly IPasteFormatExecutor _pasteFormatExecutor;
|
||||
private readonly IAICredentialsProvider _credentialsProvider;
|
||||
private readonly IPythonScriptService _pythonScriptService;
|
||||
|
||||
private CancellationTokenSource _pasteActionCancellationTokenSource;
|
||||
|
||||
@@ -102,8 +100,6 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
|
||||
|
||||
public ObservableCollection<PasteFormat> PythonScriptPasteFormats { get; } = [];
|
||||
|
||||
public bool IsCustomAIServiceEnabled
|
||||
{
|
||||
get
|
||||
@@ -238,6 +234,8 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
public bool ShowClipboardHistoryButton => ClipboardHistoryEnabled;
|
||||
|
||||
public bool ShowAIPasteSection => _userSettings.ShowAIPaste && IsAllowedByGPO;
|
||||
|
||||
public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress);
|
||||
|
||||
private PasteFormats CustomAIFormat =>
|
||||
@@ -262,12 +260,11 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
public event EventHandler PreviewRequested;
|
||||
|
||||
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor, IPythonScriptService pythonScriptService)
|
||||
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
|
||||
{
|
||||
_credentialsProvider = credentialsProvider;
|
||||
_userSettings = userSettings;
|
||||
_pasteFormatExecutor = pasteFormatExecutor;
|
||||
_pythonScriptService = pythonScriptService;
|
||||
|
||||
GeneratedResponses = [];
|
||||
GeneratedResponses.CollectionChanged += (s, e) =>
|
||||
@@ -325,6 +322,7 @@ namespace AdvancedPaste.ViewModels
|
||||
OnPropertyChanged(nameof(AIProviders));
|
||||
OnPropertyChanged(nameof(AllowedAIProviders));
|
||||
OnPropertyChanged(nameof(ShowClipboardPreview));
|
||||
OnPropertyChanged(nameof(ShowAIPasteSection));
|
||||
|
||||
NotifyActiveProviderChanged();
|
||||
|
||||
@@ -418,51 +416,12 @@ namespace AdvancedPaste.ViewModels
|
||||
}
|
||||
|
||||
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
|
||||
.Where(format => format != PasteFormats.PythonScript &&
|
||||
(PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format)))
|
||||
.Where(format => PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format))
|
||||
.Select(CreateStandardPasteFormat));
|
||||
|
||||
UpdateFormats(
|
||||
CustomActionPasteFormats,
|
||||
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []);
|
||||
|
||||
UpdateFormats(
|
||||
PythonScriptPasteFormats,
|
||||
BuildPythonScriptFormats());
|
||||
}
|
||||
|
||||
private IEnumerable<PasteFormat> BuildPythonScriptFormats()
|
||||
{
|
||||
if (!_userSettings.IsPythonScriptsEnabled)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var folder = _userSettings.PythonScriptsFolder;
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var discoveredScripts = _pythonScriptService.DiscoverScripts(folder);
|
||||
var scriptActions = _userSettings.PythonScriptActions;
|
||||
|
||||
// Use metadata from discovered scripts, but apply IsShown from saved settings.
|
||||
var hiddenPaths = new System.Collections.Generic.HashSet<string>(
|
||||
scriptActions.Where(a => !a.IsShown).Select(a => a.ScriptPath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var meta in discoveredScripts)
|
||||
{
|
||||
if (hiddenPaths.Contains(meta.ScriptPath) || !meta.IsEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by intersection: only pass clipboard formats the script supports.
|
||||
var filteredFormats = AvailableClipboardFormats & meta.SupportedFormats;
|
||||
yield return PasteFormat.CreatePythonScriptFormat(meta.Name, meta.ScriptPath, filteredFormats);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -736,10 +695,7 @@ namespace AdvancedPaste.ViewModels
|
||||
_pasteActionCancellationTokenSource = new();
|
||||
TransformProgress = double.NaN;
|
||||
PasteActionError = PasteActionError.None;
|
||||
|
||||
// For Python scripts the Prompt field holds the file path, not a user-visible query.
|
||||
// Setting Query to the path would show it in the AI prompt box, which is misleading.
|
||||
Query = pasteFormat.Format == PasteFormats.PythonScript ? string.Empty : pasteFormat.Query;
|
||||
Query = pasteFormat.Query;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -779,7 +735,7 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
internal async Task ExecutePasteFormatAsync(VirtualKey key)
|
||||
{
|
||||
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats).Concat(PythonScriptPasteFormats)
|
||||
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)
|
||||
.Where(pasteFormat => pasteFormat.IsEnabled)
|
||||
.ElementAtOrDefault(key - VirtualKey.Number1);
|
||||
|
||||
|
||||
@@ -496,23 +496,119 @@ private:
|
||||
|
||||
if (!GetGUIThreadInfo(0, &gui_info))
|
||||
{
|
||||
Logger::warn(L"Auto-copy: GetGUIThreadInfo failed (error={})", GetLastError());
|
||||
return false;
|
||||
}
|
||||
|
||||
HWND target = gui_info.hwndFocus ? gui_info.hwndFocus : gui_info.hwndActive;
|
||||
if (!target)
|
||||
{
|
||||
Logger::warn(L"Auto-copy: no focused or active window found");
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD_PTR result = 0;
|
||||
return SendMessageTimeout(target,
|
||||
WM_COPY,
|
||||
0,
|
||||
0,
|
||||
SMTO_ABORTIFHUNG | SMTO_BLOCK,
|
||||
50,
|
||||
&result) != 0;
|
||||
auto sendResult = SendMessageTimeout(target, WM_COPY, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 50, &result);
|
||||
return sendResult != 0;
|
||||
}
|
||||
|
||||
// Helper: poll clipboard sequence number for a change from initial_sequence.
|
||||
// Returns true if the sequence number changed within the given number of polls.
|
||||
bool poll_clipboard_sequence(DWORD initial_sequence, int poll_attempts, std::chrono::milliseconds poll_delay)
|
||||
{
|
||||
for (int poll = 0; poll < poll_attempts; ++poll)
|
||||
{
|
||||
if (GetClipboardSequenceNumber() != initial_sequence)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
std::this_thread::sleep_for(poll_delay);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper: send Ctrl+C via SendInput, releasing any held modifier keys first
|
||||
// (the hotkey combination may still have modifiers physically pressed).
|
||||
bool send_ctrl_c_input()
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
|
||||
// Release all modifier keys that are currently held down from the hotkey.
|
||||
// Without this, the target app sees e.g. Win+Shift+Ctrl+C instead of just Ctrl+C.
|
||||
try_inject_modifier_key_up(inputs, VK_LCONTROL);
|
||||
try_inject_modifier_key_up(inputs, VK_RCONTROL);
|
||||
try_inject_modifier_key_up(inputs, VK_LWIN);
|
||||
try_inject_modifier_key_up(inputs, VK_RWIN);
|
||||
try_inject_modifier_key_up(inputs, VK_LSHIFT);
|
||||
try_inject_modifier_key_up(inputs, VK_RSHIFT);
|
||||
try_inject_modifier_key_up(inputs, VK_LMENU);
|
||||
try_inject_modifier_key_up(inputs, VK_RMENU);
|
||||
|
||||
// Ctrl down
|
||||
{
|
||||
INPUT input_event = {};
|
||||
input_event.type = INPUT_KEYBOARD;
|
||||
input_event.ki.wVk = VK_CONTROL;
|
||||
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
|
||||
inputs.push_back(input_event);
|
||||
}
|
||||
|
||||
// C down
|
||||
{
|
||||
INPUT input_event = {};
|
||||
input_event.type = INPUT_KEYBOARD;
|
||||
input_event.ki.wVk = 0x43; // C
|
||||
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
|
||||
inputs.push_back(input_event);
|
||||
}
|
||||
|
||||
// C up
|
||||
{
|
||||
INPUT input_event = {};
|
||||
input_event.type = INPUT_KEYBOARD;
|
||||
input_event.ki.wVk = 0x43; // C
|
||||
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
|
||||
inputs.push_back(input_event);
|
||||
}
|
||||
|
||||
// Ctrl up
|
||||
{
|
||||
INPUT input_event = {};
|
||||
input_event.type = INPUT_KEYBOARD;
|
||||
input_event.ki.wVk = VK_CONTROL;
|
||||
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
|
||||
inputs.push_back(input_event);
|
||||
}
|
||||
|
||||
// Restore modifiers that were held down
|
||||
try_inject_modifier_key_restore(inputs, VK_LCONTROL);
|
||||
try_inject_modifier_key_restore(inputs, VK_RCONTROL);
|
||||
try_inject_modifier_key_restore(inputs, VK_LWIN);
|
||||
try_inject_modifier_key_restore(inputs, VK_RWIN);
|
||||
try_inject_modifier_key_restore(inputs, VK_LSHIFT);
|
||||
try_inject_modifier_key_restore(inputs, VK_RSHIFT);
|
||||
try_inject_modifier_key_restore(inputs, VK_LMENU);
|
||||
try_inject_modifier_key_restore(inputs, VK_RMENU);
|
||||
|
||||
// Prevent Start Menu from activating after Win key release/restore
|
||||
INPUT dummyEvent = {};
|
||||
dummyEvent.type = INPUT_KEYBOARD;
|
||||
dummyEvent.ki.wVk = 0xFF;
|
||||
dummyEvent.ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
inputs.push_back(dummyEvent);
|
||||
|
||||
auto uSent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT));
|
||||
if (uSent != inputs.size())
|
||||
{
|
||||
DWORD errorCode = GetLastError();
|
||||
auto errorMessage = get_last_error_message(errorCode);
|
||||
Logger::error(L"SendInput failed for Ctrl+C. Expected to send {} inputs and sent only {}. {}", inputs.size(), uSent, errorMessage.has_value() ? errorMessage.value() : L"");
|
||||
Trace::AdvancedPaste_Error(errorCode, errorMessage.has_value() ? errorMessage.value() : L"", L"input.SendInput");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool send_copy_selection()
|
||||
@@ -526,99 +622,30 @@ private:
|
||||
for (int attempt = 0; attempt < copy_attempts; ++attempt)
|
||||
{
|
||||
const auto initial_sequence = GetClipboardSequenceNumber();
|
||||
copy_succeeded = try_send_copy_message();
|
||||
|
||||
if (!copy_succeeded)
|
||||
// Strategy 1: Try WM_COPY message (works for standard Win32 controls)
|
||||
bool wm_copy_sent = try_send_copy_message();
|
||||
|
||||
if (wm_copy_sent)
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
|
||||
// Release any held modifiers (from the Advanced Paste hotkey) before sending Ctrl+C.
|
||||
// Without this, apps may receive Win+Shift+Ctrl+C instead of Ctrl+C.
|
||||
try_inject_modifier_key_up(inputs, VK_LCONTROL);
|
||||
try_inject_modifier_key_up(inputs, VK_RCONTROL);
|
||||
try_inject_modifier_key_up(inputs, VK_LWIN);
|
||||
try_inject_modifier_key_up(inputs, VK_RWIN);
|
||||
try_inject_modifier_key_up(inputs, VK_LSHIFT);
|
||||
try_inject_modifier_key_up(inputs, VK_RSHIFT);
|
||||
try_inject_modifier_key_up(inputs, VK_LMENU);
|
||||
try_inject_modifier_key_up(inputs, VK_RMENU);
|
||||
|
||||
// send Ctrl+C (key downs and key ups)
|
||||
{
|
||||
INPUT input_event = {};
|
||||
input_event.type = INPUT_KEYBOARD;
|
||||
input_event.ki.wVk = VK_CONTROL;
|
||||
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
|
||||
inputs.push_back(input_event);
|
||||
}
|
||||
|
||||
{
|
||||
INPUT input_event = {};
|
||||
input_event.type = INPUT_KEYBOARD;
|
||||
input_event.ki.wVk = 0x43; // C
|
||||
// Avoid triggering detection by the centralized keyboard hook.
|
||||
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
|
||||
inputs.push_back(input_event);
|
||||
}
|
||||
|
||||
{
|
||||
INPUT input_event = {};
|
||||
input_event.type = INPUT_KEYBOARD;
|
||||
input_event.ki.wVk = 0x43; // C
|
||||
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
// Avoid triggering detection by the centralized keyboard hook.
|
||||
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
|
||||
inputs.push_back(input_event);
|
||||
}
|
||||
|
||||
{
|
||||
INPUT input_event = {};
|
||||
input_event.type = INPUT_KEYBOARD;
|
||||
input_event.ki.wVk = VK_CONTROL;
|
||||
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
|
||||
inputs.push_back(input_event);
|
||||
}
|
||||
|
||||
// Restore modifiers that were released above.
|
||||
try_inject_modifier_key_restore(inputs, VK_LCONTROL);
|
||||
try_inject_modifier_key_restore(inputs, VK_RCONTROL);
|
||||
try_inject_modifier_key_restore(inputs, VK_LWIN);
|
||||
try_inject_modifier_key_restore(inputs, VK_RWIN);
|
||||
try_inject_modifier_key_restore(inputs, VK_LSHIFT);
|
||||
try_inject_modifier_key_restore(inputs, VK_RSHIFT);
|
||||
try_inject_modifier_key_restore(inputs, VK_LMENU);
|
||||
try_inject_modifier_key_restore(inputs, VK_RMENU);
|
||||
|
||||
auto uSent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT));
|
||||
if (uSent != inputs.size())
|
||||
{
|
||||
DWORD errorCode = GetLastError();
|
||||
auto errorMessage = get_last_error_message(errorCode);
|
||||
Logger::error(L"SendInput failed for Ctrl+C. Expected to send {} inputs and sent only {}. {}", inputs.size(), uSent, errorMessage.has_value() ? errorMessage.value() : L"");
|
||||
Trace::AdvancedPaste_Error(errorCode, errorMessage.has_value() ? errorMessage.value() : L"", L"input.SendInput");
|
||||
}
|
||||
else
|
||||
if (poll_clipboard_sequence(initial_sequence, clipboard_poll_attempts, clipboard_poll_delay))
|
||||
{
|
||||
copy_succeeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (copy_succeeded)
|
||||
// Strategy 2: If WM_COPY didn't work, try SendInput Ctrl+C (works for Electron, browsers, etc.)
|
||||
if (!copy_succeeded)
|
||||
{
|
||||
bool sequence_changed = false;
|
||||
for (int poll_attempt = 0; poll_attempt < clipboard_poll_attempts; ++poll_attempt)
|
||||
const auto sequence_before_ctrl_c = GetClipboardSequenceNumber();
|
||||
|
||||
if (send_ctrl_c_input())
|
||||
{
|
||||
if (GetClipboardSequenceNumber() != initial_sequence)
|
||||
if (poll_clipboard_sequence(sequence_before_ctrl_c, clipboard_poll_attempts, clipboard_poll_delay))
|
||||
{
|
||||
sequence_changed = true;
|
||||
break;
|
||||
copy_succeeded = true;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(clipboard_poll_delay);
|
||||
}
|
||||
|
||||
copy_succeeded = sequence_changed;
|
||||
}
|
||||
|
||||
if (copy_succeeded)
|
||||
@@ -632,6 +659,11 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
if (!copy_succeeded)
|
||||
{
|
||||
Logger::warn(L"Auto-copy: all {} copy attempts failed — the target application did not update the clipboard after WM_COPY and Ctrl+C", copy_attempts);
|
||||
}
|
||||
|
||||
return copy_succeeded;
|
||||
}
|
||||
|
||||
@@ -1004,6 +1036,7 @@ public:
|
||||
{
|
||||
if (!send_copy_selection())
|
||||
{
|
||||
Logger::warn(L"Auto-copy: failed to copy selection for custom action index {} — aborting action", custom_action_index);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"}
|
||||
{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"ShowAIPaste":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"}
|
||||
@@ -32,6 +32,17 @@ namespace EnvironmentVariables
|
||||
var loader = ResourceLoaderInstance.ResourceLoader;
|
||||
var title = App.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
|
||||
|
||||
// The WinUI TitleBar control reads the owning window's title (AppWindow.Title) during a
|
||||
// deferred layout pass. If the native window title is empty at that instant, the windowing
|
||||
// layer can fault while resolving it and terminate the process. ResourceLoader.GetString
|
||||
// returns an empty string when the resource map can't be resolved at runtime, which would
|
||||
// leave the title empty here, so fall back to a non-empty product name to keep the native
|
||||
// window title populated.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "Environment Variables";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
titleBar.Title = title;
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ namespace FileLocksmithUI
|
||||
|
||||
var loader = ResourceLoaderInstance.ResourceLoader;
|
||||
var title = isElevated ? loader.GetString("AppAdminTitle") : loader.GetString("AppTitle");
|
||||
|
||||
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
|
||||
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
|
||||
// control while it reads AppWindow.Title during a deferred layout pass.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "File Locksmith";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
titleBar.Title = title;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ namespace Hosts
|
||||
var loader = new ResourceLoader("PowerToys.HostsUILib.pri", "PowerToys.HostsUILib/Resources");
|
||||
|
||||
var title = Host.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle");
|
||||
|
||||
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
|
||||
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
|
||||
// control while it reads AppWindow.Title during a deferred layout pass.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "Hosts File Editor";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
titleBar.Title = title;
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />
|
||||
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natstepfilter" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="packages.config" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
|
||||
</packages>
|
||||
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
|
||||
</packages>
|
||||
@@ -119,7 +119,7 @@
|
||||
</resheader>
|
||||
<data name="context_menu_item_new" xml:space="preserve">
|
||||
<value>New+</value>
|
||||
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+</comment>
|
||||
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+, French it would become Nouveau+ (not Nouveauté+)</comment>
|
||||
</data>
|
||||
<data name="context_menu_item_open_templates" xml:space="preserve">
|
||||
<value>Open templates</value>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
</resheader>
|
||||
<data name="context_menu_item_new" xml:space="preserve">
|
||||
<value>New+</value>
|
||||
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+</comment>
|
||||
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+, French it would become Nouveau+ (not Nouveauté+)</comment>
|
||||
</data>
|
||||
<data name="context_menu_item_open_templates" xml:space="preserve">
|
||||
<value>Open templates</value>
|
||||
|
||||
@@ -57,7 +57,17 @@ namespace ShortcutGuide
|
||||
return _currentApplicationIds;
|
||||
});
|
||||
|
||||
Title = ResourceLoaderInstance.ResourceLoader.GetString("Title")!;
|
||||
var title = ResourceLoaderInstance.ResourceLoader.GetString("Title")!;
|
||||
|
||||
// Guard against an empty title: ResourceLoader.GetString returns "" when the resource
|
||||
// map can't be resolved, and an empty native window title can fault the WinUI TitleBar
|
||||
// control while it reads AppWindow.Title during a deferred layout pass.
|
||||
if (string.IsNullOrEmpty(title))
|
||||
{
|
||||
title = "Shortcut Guide";
|
||||
}
|
||||
|
||||
Title = title;
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
|
||||
#if !DEBUG
|
||||
|
||||
@@ -46,7 +46,7 @@ void LauncherUIHelper::LaunchUI()
|
||||
GetModuleFileName(NULL, buffer, MAX_PATH);
|
||||
std::wstring path = std::filesystem::path(buffer).parent_path();
|
||||
|
||||
auto res = AppLauncher::LaunchApp(path + L"\\PowerToys.WorkspacesLauncherUI.exe", L"", false);
|
||||
auto res = AppLauncher::LaunchApp(path + L"\\WinUI3Apps\\PowerToys.WorkspacesLauncherUI.exe", L"", false);
|
||||
if (res.isOk())
|
||||
{
|
||||
auto value = res.value();
|
||||
|
||||
@@ -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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using WorkspacesLauncherUI.Data;
|
||||
|
||||
namespace WorkspacesLauncherUI.UnitTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for ApplicationWrapper struct field mapping.
|
||||
/// All fields must be accessible and hold correct values after deserialization.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class ApplicationDataModelTests
|
||||
{
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_ApplicationName_StoresDisplayName()
|
||||
{
|
||||
var app = new ApplicationWrapper { Application = "Visual Studio Code" };
|
||||
Assert.AreEqual("Visual Studio Code", app.Application);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_ExecutablePath_StoresFullPathWithSpaces()
|
||||
{
|
||||
var app = new ApplicationWrapper { ApplicationPath = @"C:\Users\test\AppData\Local\Programs\Microsoft VS Code\Code.exe" };
|
||||
Assert.AreEqual(@"C:\Users\test\AppData\Local\Programs\Microsoft VS Code\Code.exe", app.ApplicationPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_WindowTitle_StoresActiveWindowTitle()
|
||||
{
|
||||
var app = new ApplicationWrapper { Title = "MyProject - Visual Studio Code" };
|
||||
Assert.AreEqual("MyProject - Visual Studio Code", app.Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_PackageFullName_StoresUwpPackageIdentifier()
|
||||
{
|
||||
var app = new ApplicationWrapper { PackageFullName = "Microsoft.WindowsTerminal_1.21.0.0_x64__8wekyb3d8bbwe" };
|
||||
Assert.AreEqual("Microsoft.WindowsTerminal_1.21.0.0_x64__8wekyb3d8bbwe", app.PackageFullName);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_AppUserModelId_StoresAumidForPackagedApps()
|
||||
{
|
||||
var app = new ApplicationWrapper { AppUserModelId = "Microsoft.WindowsTerminal_8wekyb3d8bbwe!App" };
|
||||
Assert.AreEqual("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", app.AppUserModelId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_PwaAppId_StoresChromeOrEdgePwaIdentifier()
|
||||
{
|
||||
var app = new ApplicationWrapper { PwaAppId = "fmgjjmmmlfnkbppncijlocphclkkleod" };
|
||||
Assert.AreEqual("fmgjjmmmlfnkbppncijlocphclkkleod", app.PwaAppId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_CliArguments_StoresLaunchArgumentsExactly()
|
||||
{
|
||||
var app = new ApplicationWrapper { CommandLineArguments = "--reuse-window --goto file.ts:42" };
|
||||
Assert.AreEqual("--reuse-window --goto file.ts:42", app.CommandLineArguments);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_IsElevated_StoresAdminRunningState()
|
||||
{
|
||||
var app = new ApplicationWrapper { IsElevated = true };
|
||||
Assert.IsTrue(app.IsElevated);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_CanLaunchElevated_StoresElevationCapability()
|
||||
{
|
||||
var app = new ApplicationWrapper { CanLaunchElevated = true };
|
||||
Assert.IsTrue(app.CanLaunchElevated);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_Minimized_StoresMinimizedWindowState()
|
||||
{
|
||||
var app = new ApplicationWrapper { Minimized = true };
|
||||
Assert.IsTrue(app.Minimized);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_Maximized_StoresMaximizedWindowState()
|
||||
{
|
||||
var app = new ApplicationWrapper { Maximized = true };
|
||||
Assert.IsTrue(app.Maximized);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_MonitorIndex_StoresTargetDisplayNumber()
|
||||
{
|
||||
var app = new ApplicationWrapper { Monitor = 2 };
|
||||
Assert.AreEqual(2, app.Monitor);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppField_WindowPosition_StoresRectangleCoordinates()
|
||||
{
|
||||
var pos = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
|
||||
var app = new ApplicationWrapper { Position = pos };
|
||||
|
||||
Assert.AreEqual(100, app.Position.X);
|
||||
Assert.AreEqual(200, app.Position.Y);
|
||||
Assert.AreEqual(800, app.Position.Width);
|
||||
Assert.AreEqual(600, app.Position.Height);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppDefaults_StringFields_AreNullBeforeDeserialization()
|
||||
{
|
||||
ApplicationWrapper app = default;
|
||||
|
||||
Assert.IsNull(app.Application);
|
||||
Assert.IsNull(app.ApplicationPath);
|
||||
Assert.IsNull(app.Title);
|
||||
Assert.IsNull(app.PackageFullName);
|
||||
Assert.IsNull(app.AppUserModelId);
|
||||
Assert.IsNull(app.PwaAppId);
|
||||
Assert.IsNull(app.CommandLineArguments);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppDefaults_BooleanFields_AreFalseBeforeDeserialization()
|
||||
{
|
||||
ApplicationWrapper app = default;
|
||||
|
||||
Assert.IsFalse(app.IsElevated);
|
||||
Assert.IsFalse(app.CanLaunchElevated);
|
||||
Assert.IsFalse(app.Minimized);
|
||||
Assert.IsFalse(app.Maximized);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppDefaults_MonitorIndex_IsZeroPrimaryMonitor()
|
||||
{
|
||||
ApplicationWrapper app = default;
|
||||
Assert.AreEqual(0, app.Monitor);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppConfig_AdminAppOnSecondMonitor_AllFieldsPopulated()
|
||||
{
|
||||
var app = new ApplicationWrapper
|
||||
{
|
||||
Application = "Registry Editor",
|
||||
ApplicationPath = @"C:\Windows\regedit.exe",
|
||||
Title = "Registry Editor",
|
||||
PackageFullName = string.Empty,
|
||||
AppUserModelId = string.Empty,
|
||||
PwaAppId = string.Empty,
|
||||
CommandLineArguments = string.Empty,
|
||||
IsElevated = true,
|
||||
CanLaunchElevated = true,
|
||||
Minimized = false,
|
||||
Maximized = false,
|
||||
Position = new PositionWrapper { X = 1920, Y = 0, Width = 1024, Height = 768 },
|
||||
Monitor = 1,
|
||||
};
|
||||
|
||||
Assert.IsTrue(app.IsElevated);
|
||||
Assert.AreEqual(1, app.Monitor);
|
||||
Assert.AreEqual(1920, app.Position.X);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppConfig_MinimizedOnThirdMonitor_StateAndMonitorCorrect()
|
||||
{
|
||||
var app = new ApplicationWrapper
|
||||
{
|
||||
Application = "Notepad",
|
||||
ApplicationPath = @"C:\Windows\System32\notepad.exe",
|
||||
Minimized = true,
|
||||
Maximized = false,
|
||||
Position = new PositionWrapper { X = 3840, Y = 0, Width = 800, Height = 600 },
|
||||
Monitor = 2,
|
||||
};
|
||||
|
||||
Assert.IsTrue(app.Minimized);
|
||||
Assert.IsFalse(app.Maximized);
|
||||
Assert.AreEqual(2, app.Monitor);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppConfig_PathWithParenthesesAndSpaces_PreservedExactly()
|
||||
{
|
||||
string complexPath = @"C:\Program Files (x86)\Microsoft Office\root\Office16\WINWORD.EXE";
|
||||
var app = new ApplicationWrapper { ApplicationPath = complexPath };
|
||||
Assert.AreEqual(complexPath, app.ApplicationPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void AppConfig_ExplicitEmptyStrings_AreEmptyNotNull()
|
||||
{
|
||||
var app = new ApplicationWrapper
|
||||
{
|
||||
Application = string.Empty,
|
||||
ApplicationPath = string.Empty,
|
||||
Title = string.Empty,
|
||||
PackageFullName = string.Empty,
|
||||
AppUserModelId = string.Empty,
|
||||
PwaAppId = string.Empty,
|
||||
CommandLineArguments = string.Empty,
|
||||
};
|
||||
|
||||
Assert.AreEqual(string.Empty, app.Application);
|
||||
Assert.AreEqual(string.Empty, app.ApplicationPath);
|
||||
Assert.AreEqual(string.Empty, app.PackageFullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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;
|
||||
using WorkspacesLauncherUI.Utils;
|
||||
|
||||
namespace WorkspacesLauncherUI.UnitTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for DashCaseNamingPolicy and StringUtils.
|
||||
/// These utilities control JSON property name mapping for IPC messages.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class IpcJsonPropertyNamingTests
|
||||
{
|
||||
private readonly DashCaseNamingPolicy _policy = DashCaseNamingPolicy.Instance;
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_ApplicationPath_MapsTo_application_path()
|
||||
{
|
||||
Assert.AreEqual("application-path", _policy.ConvertName("ApplicationPath"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_Application_MapsTo_application()
|
||||
{
|
||||
Assert.AreEqual("application", _policy.ConvertName("Application"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_AppUserModelId_MapsTo_app_user_model_id()
|
||||
{
|
||||
Assert.AreEqual("app-user-model-id", _policy.ConvertName("AppUserModelId"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_LowercaseInput_RemainsUnchanged()
|
||||
{
|
||||
Assert.AreEqual("title", _policy.ConvertName("title"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_SingleUppercaseChar_PreservedAsIs()
|
||||
{
|
||||
Assert.AreEqual("X", _policy.ConvertName("X"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_SingleLowercaseChar_PreservedAsIs()
|
||||
{
|
||||
Assert.AreEqual("x", _policy.ConvertName("x"));
|
||||
}
|
||||
|
||||
// Exact IPC property names that must match the C++ side
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_PackageFullName_MatchesCppIpcKey()
|
||||
{
|
||||
Assert.AreEqual("package-full-name", _policy.ConvertName("PackageFullName"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_AppUserModelId_MatchesCppIpcKey()
|
||||
{
|
||||
Assert.AreEqual("app-user-model-id", _policy.ConvertName("AppUserModelId"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_PwaAppId_MatchesCppIpcKey()
|
||||
{
|
||||
Assert.AreEqual("pwa-app-id", _policy.ConvertName("PwaAppId"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_CommandLineArguments_MatchesCppIpcKey()
|
||||
{
|
||||
Assert.AreEqual("command-line-arguments", _policy.ConvertName("CommandLineArguments"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_IsElevated_MatchesCppIpcKey()
|
||||
{
|
||||
Assert.AreEqual("is-elevated", _policy.ConvertName("IsElevated"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_CanLaunchElevated_MatchesCppIpcKey()
|
||||
{
|
||||
Assert.AreEqual("can-launch-elevated", _policy.ConvertName("CanLaunchElevated"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_ApplicationPath_MatchesCppIpcKey()
|
||||
{
|
||||
Assert.AreEqual("application-path", _policy.ConvertName("ApplicationPath"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void NamingPolicy_Singleton_ReturnsSameInstanceEveryTime()
|
||||
{
|
||||
var instance1 = DashCaseNamingPolicy.Instance;
|
||||
var instance2 = DashCaseNamingPolicy.Instance;
|
||||
Assert.AreSame(instance1, instance2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void StringConversion_TwoUppercaseLetters_InsertsDashBetween()
|
||||
{
|
||||
Assert.AreEqual("a-b", "AB".UpperCamelCaseToDashCase());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void StringConversion_AllLowercase_NoTransformation()
|
||||
{
|
||||
Assert.AreEqual("alllowercase", "alllowercase".UpperCamelCaseToDashCase());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Serialization")]
|
||||
public void StringConversion_NumbersInMiddle_PreservedWithDashBeforeNextUpper()
|
||||
{
|
||||
Assert.AreEqual("version2-test", "Version2Test".UpperCamelCaseToDashCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// 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.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using WorkspacesLauncherUI.Data;
|
||||
|
||||
namespace WorkspacesLauncherUI.UnitTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for JSON deserialization of IPC messages received from the C++ launcher engine.
|
||||
/// These messages drive the entire Launcher UI state and must remain stable
|
||||
/// across any future UI or data layer changes.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class IpcMessageDeserializationTests
|
||||
{
|
||||
private const string FullIpcMessage = @"{
|
||||
""processId"": 12345,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Visual Studio Code"",
|
||||
""application-path"": ""C:\\Users\\test\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe"",
|
||||
""title"": ""MyProject - Visual Studio Code"",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": ""--reuse-window"",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": true,
|
||||
""minimized"": false,
|
||||
""maximized"": true,
|
||||
""position"": { ""X"": 0, ""Y"": 0, ""width"": 1920, ""height"": 1080 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 2
|
||||
},
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Windows Terminal"",
|
||||
""application-path"": ""C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe\\wt.exe"",
|
||||
""title"": ""PowerShell"",
|
||||
""package-full-name"": ""Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe"",
|
||||
""app-user-model-id"": ""Microsoft.WindowsTerminal_8wekyb3d8bbwe!App"",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": false,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 960, ""Y"": 0, ""width"": 960, ""height"": 540 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 0
|
||||
},
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Notepad"",
|
||||
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
|
||||
""title"": ""Untitled - Notepad"",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": true,
|
||||
""minimized"": true,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 100, ""Y"": 100, ""width"": 800, ""height"": 600 },
|
||||
""monitor"": 1
|
||||
},
|
||||
""state"": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_WithMultipleApps_ExtractsLauncherProcessId()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
Assert.AreEqual(12345, result.LauncherProcessID);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_WithThreeApps_DeserializesAllAppEntries()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
Assert.AreEqual(3, result.AppLaunchInfos.AppLaunchInfoList.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_Win32Application_DeserializesAllApplicationFields()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
var vscode = result.AppLaunchInfos.AppLaunchInfoList[0];
|
||||
|
||||
Assert.AreEqual("Visual Studio Code", vscode.Application.Application);
|
||||
Assert.AreEqual(@"C:\Users\test\AppData\Local\Programs\Microsoft VS Code\Code.exe", vscode.Application.ApplicationPath);
|
||||
Assert.AreEqual("MyProject - Visual Studio Code", vscode.Application.Title);
|
||||
Assert.AreEqual(string.Empty, vscode.Application.PackageFullName);
|
||||
Assert.AreEqual(string.Empty, vscode.Application.AppUserModelId);
|
||||
Assert.AreEqual(string.Empty, vscode.Application.PwaAppId);
|
||||
Assert.AreEqual("--reuse-window", vscode.Application.CommandLineArguments);
|
||||
Assert.IsFalse(vscode.Application.IsElevated);
|
||||
Assert.IsTrue(vscode.Application.CanLaunchElevated);
|
||||
Assert.IsFalse(vscode.Application.Minimized);
|
||||
Assert.IsTrue(vscode.Application.Maximized);
|
||||
Assert.AreEqual(0, vscode.Application.Monitor);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_Win32Application_DeserializesWindowPosition()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
var pos = result.AppLaunchInfos.AppLaunchInfoList[0].Application.Position;
|
||||
|
||||
Assert.AreEqual(0, pos.X);
|
||||
Assert.AreEqual(0, pos.Y);
|
||||
Assert.AreEqual(1920, pos.Width);
|
||||
Assert.AreEqual(1080, pos.Height);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_PackagedUwpApp_DeserializesPackageIdentifiers()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
var terminal = result.AppLaunchInfos.AppLaunchInfoList[1];
|
||||
|
||||
Assert.AreEqual("Windows Terminal", terminal.Application.Application);
|
||||
Assert.AreEqual("Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe", terminal.Application.PackageFullName);
|
||||
Assert.AreEqual("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", terminal.Application.AppUserModelId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_StateValueTwo_MapsToLaunchedAndMovedEnum()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
Assert.AreEqual(LaunchingState.LaunchedAndMoved, result.AppLaunchInfos.AppLaunchInfoList[0].State);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_StateValueZero_MapsToWaitingEnum()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
Assert.AreEqual(LaunchingState.Waiting, result.AppLaunchInfos.AppLaunchInfoList[1].State);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_StateValueThree_MapsToFailedEnum()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
Assert.AreEqual(LaunchingState.Failed, result.AppLaunchInfos.AppLaunchInfoList[2].State);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_MinimizedWindow_DeserializesWindowStateFlags()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
var notepad = result.AppLaunchInfos.AppLaunchInfoList[2];
|
||||
|
||||
Assert.IsTrue(notepad.Application.Minimized);
|
||||
Assert.IsFalse(notepad.Application.Maximized);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_SecondaryMonitor_DeserializesMonitorIndex()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(FullIpcMessage);
|
||||
Assert.AreEqual(1, result.AppLaunchInfos.AppLaunchInfoList[2].Application.Monitor);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_ProgressiveWebApp_DeserializesPwaIdentifier()
|
||||
{
|
||||
string pwaMessage = @"{
|
||||
""processId"": 100,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Gmail"",
|
||||
""application-path"": ""C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"",
|
||||
""title"": ""Gmail"",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": ""fmgjjmmmlfnkbppncijlocphclkkleod"",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": false,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 0, ""Y"": 0, ""width"": 800, ""height"": 600 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(pwaMessage);
|
||||
var gmail = result.AppLaunchInfos.AppLaunchInfoList[0];
|
||||
|
||||
Assert.AreEqual("fmgjjmmmlfnkbppncijlocphclkkleod", gmail.Application.PwaAppId);
|
||||
Assert.AreEqual(LaunchingState.Launched, gmail.State);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_ElevatedProcess_DeserializesAdminFlags()
|
||||
{
|
||||
string elevatedMessage = @"{
|
||||
""processId"": 200,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Registry Editor"",
|
||||
""application-path"": ""C:\\Windows\\regedit.exe"",
|
||||
""title"": ""Registry Editor"",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": true,
|
||||
""can-launch-elevated"": true,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 100, ""Y"": 100, ""width"": 1024, ""height"": 768 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(elevatedMessage);
|
||||
var regedit = result.AppLaunchInfos.AppLaunchInfoList[0];
|
||||
|
||||
Assert.IsTrue(regedit.Application.IsElevated);
|
||||
Assert.IsTrue(regedit.Application.CanLaunchElevated);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_SingleAppWorkspace_DeserializesSuccessfully()
|
||||
{
|
||||
string singleAppMessage = @"{
|
||||
""processId"": 1,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Notepad"",
|
||||
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
|
||||
""title"": """",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": false,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 0, ""Y"": 0, ""width"": 400, ""height"": 300 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(singleAppMessage);
|
||||
|
||||
Assert.AreEqual(1, result.AppLaunchInfos.AppLaunchInfoList.Count);
|
||||
Assert.AreEqual("Notepad", result.AppLaunchInfos.AppLaunchInfoList[0].Application.Application);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_ZeroApps_ReturnsEmptyListWithValidProcessId()
|
||||
{
|
||||
string emptyAppsMessage = @"{
|
||||
""processId"": 42,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": []
|
||||
}
|
||||
}";
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(emptyAppsMessage);
|
||||
|
||||
Assert.AreEqual(42, result.LauncherProcessID);
|
||||
Assert.AreEqual(0, result.AppLaunchInfos.AppLaunchInfoList.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
[ExpectedException(typeof(JsonException))]
|
||||
public void IpcMessage_MalformedJson_ThrowsJsonException()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
parser.Deserialize("not valid json {{{");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
[ExpectedException(typeof(JsonException))]
|
||||
public void IpcMessage_EmptyPayload_ThrowsJsonException()
|
||||
{
|
||||
var parser = new AppLaunchData();
|
||||
parser.Deserialize(string.Empty);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_LeftOfPrimaryMonitor_DeserializesNegativeCoordinates()
|
||||
{
|
||||
string negativePositionMessage = @"{
|
||||
""processId"": 1,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Notepad"",
|
||||
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
|
||||
""title"": """",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": false,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": -1920, ""Y"": -200, ""width"": 800, ""height"": 600 },
|
||||
""monitor"": 1
|
||||
},
|
||||
""state"": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(negativePositionMessage);
|
||||
var pos = result.AppLaunchInfos.AppLaunchInfoList[0].Application.Position;
|
||||
|
||||
Assert.AreEqual(-1920, pos.X);
|
||||
Assert.AreEqual(-200, pos.Y);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_FourthMonitor_DeserializesHighMonitorIndex()
|
||||
{
|
||||
string multiMonitorMessage = @"{
|
||||
""processId"": 1,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""App"",
|
||||
""application-path"": ""C:\\app.exe"",
|
||||
""title"": """",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": false,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 3840, ""Y"": 0, ""width"": 1920, ""height"": 1080 },
|
||||
""monitor"": 3
|
||||
},
|
||||
""state"": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(multiMonitorMessage);
|
||||
Assert.AreEqual(3, result.AppLaunchInfos.AppLaunchInfoList[0].Application.Monitor);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_AllFiveStateValues_MapToCorrectEnumMembers()
|
||||
{
|
||||
for (int stateValue = 0; stateValue <= 4; stateValue++)
|
||||
{
|
||||
string template = @"{""processId"": 1,""apps"": {""appLaunchInfos"": [{""application"": {""application"": ""App"",""application-path"": ""C:\\app.exe"",""title"": """",""package-full-name"": """",""app-user-model-id"": """",""pwa-app-id"": """",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },""monitor"": 0},""state"": STATE_PLACEHOLDER}]}}";
|
||||
string message = template.Replace("STATE_PLACEHOLDER", stateValue.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(message);
|
||||
Assert.AreEqual((LaunchingState)stateValue, result.AppLaunchInfos.AppLaunchInfoList[0].State);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_CommandLineWithSpecialChars_PreservesArgumentsExactly()
|
||||
{
|
||||
string cliMessage = @"{
|
||||
""processId"": 1,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""VS Code"",
|
||||
""application-path"": ""C:\\Code.exe"",
|
||||
""title"": """",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": ""--new-window --goto C:\\project\\file.ts:42"",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": false,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(cliMessage);
|
||||
Assert.AreEqual(@"--new-window --goto C:\project\file.ts:42", result.AppLaunchInfos.AppLaunchInfoList[0].Application.CommandLineArguments);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_JapaneseAppName_DeserializesUnicodeCorrectly()
|
||||
{
|
||||
string unicodeMessage = @"{
|
||||
""processId"": 1,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""\u30E1\u30E2\u5E33"",
|
||||
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
|
||||
""title"": ""\u7121\u984C - \u30E1\u30E2\u5E33"",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": false,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 0, ""Y"": 0, ""width"": 400, ""height"": 300 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(unicodeMessage);
|
||||
|
||||
Assert.AreEqual("\u30E1\u30E2\u5E33", result.AppLaunchInfos.AppLaunchInfoList[0].Application.Application);
|
||||
Assert.AreEqual("\u7121\u984C - \u30E1\u30E2\u5E33", result.AppLaunchInfos.AppLaunchInfoList[0].Application.Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Deserialization")]
|
||||
public void IpcMessage_TenAppWorkspace_DeserializesAllWithCorrectPositionsAndStates()
|
||||
{
|
||||
var appEntries = new StringBuilder();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
appEntries.Append(',');
|
||||
}
|
||||
|
||||
string entry = string.Create(CultureInfo.InvariantCulture, $@"{{""application"": {{""application"": ""App{i}"",""application-path"": ""C:\\app{i}.exe"",""title"": ""Window {i}"",""package-full-name"": """",""app-user-model-id"": """",""pwa-app-id"": """",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": {{ ""X"": {i * 100}, ""Y"": 0, ""width"": 400, ""height"": 300 }},""monitor"": {i % 3}}},""state"": {i % 5}}}");
|
||||
appEntries.Append(entry);
|
||||
}
|
||||
|
||||
string manyAppsMessage = string.Create(CultureInfo.InvariantCulture, $@"{{""processId"": 9999,""apps"": {{""appLaunchInfos"": [{appEntries}]}}}}");
|
||||
|
||||
var parser = new AppLaunchData();
|
||||
var result = parser.Deserialize(manyAppsMessage);
|
||||
|
||||
Assert.AreEqual(10, result.AppLaunchInfos.AppLaunchInfoList.Count);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.AreEqual(string.Create(CultureInfo.InvariantCulture, $"App{i}"), result.AppLaunchInfos.AppLaunchInfoList[i].Application.Application);
|
||||
Assert.AreEqual(i * 100, result.AppLaunchInfos.AppLaunchInfoList[i].Application.Position.X);
|
||||
Assert.AreEqual(i % 3, result.AppLaunchInfos.AppLaunchInfoList[i].Application.Monitor);
|
||||
Assert.AreEqual((LaunchingState)(i % 5), result.AppLaunchInfos.AppLaunchInfoList[i].State);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using WorkspacesLauncherUI.Data;
|
||||
|
||||
namespace WorkspacesLauncherUI.UnitTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the LaunchingState enum values and their integer mapping.
|
||||
/// The C++ launcher engine sends state as integer values over IPC.
|
||||
/// These integer values MUST remain stable across the migration.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class LaunchStateEnumContractTests
|
||||
{
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void EnumContract_WaitingState_MapsToIntegerZero()
|
||||
{
|
||||
Assert.AreEqual(0, (int)LaunchingState.Waiting);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void EnumContract_LaunchedState_MapsToIntegerOne()
|
||||
{
|
||||
Assert.AreEqual(1, (int)LaunchingState.Launched);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void EnumContract_LaunchedAndMovedState_MapsToIntegerTwo()
|
||||
{
|
||||
Assert.AreEqual(2, (int)LaunchingState.LaunchedAndMoved);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void EnumContract_FailedState_MapsToIntegerThree()
|
||||
{
|
||||
Assert.AreEqual(3, (int)LaunchingState.Failed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void EnumContract_CanceledState_MapsToIntegerFour()
|
||||
{
|
||||
Assert.AreEqual(4, (int)LaunchingState.Canceled);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void EnumContract_TotalMemberCount_IsExactlyFiveMatchingCppHeader()
|
||||
{
|
||||
var values = Enum.GetValues(typeof(LaunchingState));
|
||||
Assert.AreEqual(5, values.Length, "LaunchingState must have exactly 5 values to match C++ LaunchingStateEnum.h");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void EnumContract_IntToEnumCast_RoundTripsForAllValues()
|
||||
{
|
||||
for (int i = 0; i <= 4; i++)
|
||||
{
|
||||
var state = (LaunchingState)i;
|
||||
Assert.AreEqual(i, (int)state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// 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;
|
||||
using WorkspacesLauncherUI.Data;
|
||||
using WorkspacesLauncherUI.Models;
|
||||
|
||||
namespace WorkspacesLauncherUI.UnitTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the AppLaunching model which drives UI display:
|
||||
/// loading indicator, state glyph, and state color.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class LaunchStatusDisplayLogicTests
|
||||
{
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void LoadingSpinner_WhenStateIsWaiting_IsVisible()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.Waiting };
|
||||
Assert.IsTrue(app.Loading);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void LoadingSpinner_WhenStateIsLaunched_RemainsVisibleUntilMoved()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.Launched };
|
||||
Assert.IsTrue(app.Loading);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void LoadingSpinner_WhenStateIsLaunchedAndMoved_IsHidden()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.LaunchedAndMoved };
|
||||
Assert.IsFalse(app.Loading);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void LoadingSpinner_WhenStateIsFailed_IsHidden()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.Failed };
|
||||
Assert.IsFalse(app.Loading);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void LoadingSpinner_WhenStateIsCanceled_IsHidden()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.Canceled };
|
||||
Assert.IsFalse(app.Loading);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void StatusIcon_WhenSuccessful_ShowsGreenCheckmarkGlyph()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.LaunchedAndMoved };
|
||||
Assert.AreEqual("\U0000F78C", app.StateGlyph, "LaunchedAndMoved should show checkmark glyph");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void StatusIcon_WhenFailed_ShowsRedErrorGlyph()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.Failed };
|
||||
Assert.AreEqual("\U0000EF2C", app.StateGlyph, "Failed should show error glyph");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void StatusIcon_WhenCanceled_ShowsRedErrorGlyph()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.Canceled };
|
||||
Assert.AreEqual("\U0000EF2C", app.StateGlyph, "Canceled should fall through to default error glyph");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void StatusColor_WhenSuccessful_IsGreenRgb0_128_0()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.LaunchedAndMoved };
|
||||
var color = app.StateColorValue;
|
||||
|
||||
Assert.AreNotEqual(default(Windows.UI.Color), color);
|
||||
Assert.AreEqual(0, color.R, "Green color R component");
|
||||
Assert.AreEqual(128, color.G, "Green color G component");
|
||||
Assert.AreEqual(0, color.B, "Green color B component");
|
||||
Assert.AreEqual(255, color.A, "Green color A component");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void StatusColor_WhenFailed_IsRedRgb254_0_0()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.Failed };
|
||||
var color = app.StateColorValue;
|
||||
|
||||
Assert.AreNotEqual(default(Windows.UI.Color), color);
|
||||
Assert.AreEqual(254, color.R, "Red color R component");
|
||||
Assert.AreEqual(0, color.G, "Red color G component");
|
||||
Assert.AreEqual(0, color.B, "Red color B component");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void StatusColor_WhenCanceled_IsRedRgb254_0_0()
|
||||
{
|
||||
var app = new AppLaunching { LaunchState = LaunchingState.Canceled };
|
||||
var color = app.StateColorValue;
|
||||
|
||||
Assert.AreNotEqual(default(Windows.UI.Color), color);
|
||||
Assert.AreEqual(254, color.R, "Canceled should fall through to red");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void AppName_SetToString_ReturnsExactValue()
|
||||
{
|
||||
var app = new AppLaunching { Name = "Test Application" };
|
||||
Assert.AreEqual("Test Application", app.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void AppName_SetToEmpty_ReturnsEmptyString()
|
||||
{
|
||||
var app = new AppLaunching { Name = string.Empty };
|
||||
Assert.AreEqual(string.Empty, app.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void StateProgression_WaitingToSuccess_TransitionsSpinnerToGreenCheckmark()
|
||||
{
|
||||
var app = new AppLaunching { Name = "Test", LaunchState = LaunchingState.Waiting };
|
||||
Assert.IsTrue(app.Loading);
|
||||
|
||||
app.LaunchState = LaunchingState.Launched;
|
||||
Assert.IsTrue(app.Loading);
|
||||
|
||||
app.LaunchState = LaunchingState.LaunchedAndMoved;
|
||||
Assert.IsFalse(app.Loading);
|
||||
Assert.AreEqual("\U0000F78C", app.StateGlyph);
|
||||
var color = app.StateColorValue;
|
||||
Assert.AreEqual(0, color.R);
|
||||
Assert.AreEqual(128, color.G);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Model")]
|
||||
public void StateProgression_WaitingToFailed_TransitionsSpinnerToRedError()
|
||||
{
|
||||
var app = new AppLaunching { Name = "Test", LaunchState = LaunchingState.Waiting };
|
||||
Assert.IsTrue(app.Loading);
|
||||
|
||||
app.LaunchState = LaunchingState.Failed;
|
||||
Assert.IsFalse(app.Loading);
|
||||
Assert.AreEqual("\U0000EF2C", app.StateGlyph);
|
||||
var color = app.StateColorValue;
|
||||
Assert.AreEqual(254, color.R);
|
||||
Assert.AreEqual(0, color.G);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// 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.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using WorkspacesLauncherUI.Data;
|
||||
using WorkspacesLauncherUI.Models;
|
||||
using WorkspacesLauncherUI.ViewModels;
|
||||
|
||||
namespace WorkspacesLauncherUI.UnitTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for MainViewModel IPC message handling and state management.
|
||||
/// MainViewModel is the core of the Launcher UI — it receives IPC messages
|
||||
/// from the C++ launcher engine and populates the AppsListed collection
|
||||
/// that the UI binds to.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class LauncherViewModelStateManagementTests
|
||||
{
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_ValidPayload_PopulatesAppsListedCollection()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
string message = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.Waiting), ("App2", @"C:\app2.exe", LaunchingState.Launched));
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.AreEqual(2, vm.AppsListed.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_ValidPayload_MapsAppNamesFromJson()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
string message = CreateIpcMessage(("Visual Studio Code", @"C:\Code.exe", LaunchingState.Waiting), ("Windows Terminal", @"C:\wt.exe", LaunchingState.Launched));
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.AreEqual("Visual Studio Code", vm.AppsListed[0].Name);
|
||||
Assert.AreEqual("Windows Terminal", vm.AppsListed[1].Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_MixedStates_MapsEachAppToCorrectState()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
string message = CreateIpcMessage(
|
||||
("App1", @"C:\app1.exe", LaunchingState.Waiting),
|
||||
("App2", @"C:\app2.exe", LaunchingState.Launched),
|
||||
("App3", @"C:\app3.exe", LaunchingState.LaunchedAndMoved),
|
||||
("App4", @"C:\app4.exe", LaunchingState.Failed));
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.AreEqual(LaunchingState.Waiting, vm.AppsListed[0].LaunchState);
|
||||
Assert.AreEqual(LaunchingState.Launched, vm.AppsListed[1].LaunchState);
|
||||
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[2].LaunchState);
|
||||
Assert.AreEqual(LaunchingState.Failed, vm.AppsListed[3].LaunchState);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_ValidPayload_PreservesExecutablePaths()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
string message = CreateIpcMessage(("Notepad", @"C:\Windows\System32\notepad.exe", LaunchingState.Waiting));
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.AreEqual(@"C:\Windows\System32\notepad.exe", vm.AppsListed[0].AppPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_PackagedApp_MapsPackageNameAndAumid()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
string message = @"{
|
||||
""processId"": 1,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Terminal"",
|
||||
""application-path"": ""C:\\wt.exe"",
|
||||
""title"": """",
|
||||
""package-full-name"": ""Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe"",
|
||||
""app-user-model-id"": ""Microsoft.WindowsTerminal_8wekyb3d8bbwe!App"",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": false,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.AreEqual("Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe", vm.AppsListed[0].PackagedName);
|
||||
Assert.AreEqual("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", vm.AppsListed[0].Aumid);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_PwaApp_MapsPwaAppIdentifier()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
string message = @"{
|
||||
""processId"": 1,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Gmail"",
|
||||
""application-path"": ""C:\\chrome.exe"",
|
||||
""title"": """",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": ""abc123"",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": false,
|
||||
""can-launch-elevated"": false,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.AreEqual("abc123", vm.AppsListed[0].PwaAppId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_AnyUpdate_RaisesPropertyChangedForDataBinding()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
bool propertyChangedFired = false;
|
||||
string changedPropertyName = null;
|
||||
|
||||
vm.PropertyChanged += (sender, args) =>
|
||||
{
|
||||
propertyChangedFired = true;
|
||||
changedPropertyName = args.PropertyName;
|
||||
};
|
||||
|
||||
string message = CreateIpcMessage(("App", @"C:\app.exe", LaunchingState.Waiting));
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.IsTrue(propertyChangedFired, "PropertyChanged should fire when AppsListed is updated");
|
||||
Assert.AreEqual("AppsListed", changedPropertyName);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_ProgressUpdates_ReplacesEntireCollectionEachTime()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
string msg1 = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.Waiting), ("App2", @"C:\app2.exe", LaunchingState.Waiting));
|
||||
SimulateIpcMessage(msg1);
|
||||
Assert.AreEqual(2, vm.AppsListed.Count);
|
||||
Assert.AreEqual(LaunchingState.Waiting, vm.AppsListed[0].LaunchState);
|
||||
|
||||
string msg2 = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.Launched), ("App2", @"C:\app2.exe", LaunchingState.Waiting));
|
||||
SimulateIpcMessage(msg2);
|
||||
Assert.AreEqual(2, vm.AppsListed.Count);
|
||||
Assert.AreEqual(LaunchingState.Launched, vm.AppsListed[0].LaunchState);
|
||||
|
||||
string msg3 = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved), ("App2", @"C:\app2.exe", LaunchingState.LaunchedAndMoved));
|
||||
SimulateIpcMessage(msg3);
|
||||
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
|
||||
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[1].LaunchState);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_SomeAppsFail_AllowsMixedSuccessAndFailure()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
string message = CreateIpcMessage(
|
||||
("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved),
|
||||
("App2", @"C:\app2.exe", LaunchingState.Failed),
|
||||
("App3", @"C:\app3.exe", LaunchingState.LaunchedAndMoved));
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
|
||||
Assert.AreEqual(LaunchingState.Failed, vm.AppsListed[1].LaunchState);
|
||||
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[2].LaunchState);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_CanceledState_ReflectedInCollection()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
string message = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved), ("App2", @"C:\app2.exe", LaunchingState.Canceled));
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.AreEqual(LaunchingState.Canceled, vm.AppsListed[1].LaunchState);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_EmptyAppList_SetsCollectionToEmpty()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
string message = @"{ ""processId"": 1, ""apps"": { ""appLaunchInfos"": [] } }";
|
||||
SimulateIpcMessage(message);
|
||||
|
||||
Assert.AreEqual(0, vm.AppsListed.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_CorruptedPayload_GracefullyIgnoredWithoutCrash()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
SimulateIpcMessage("this is not json");
|
||||
|
||||
Assert.AreEqual(0, vm.AppsListed.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_EmptyString_GracefullyIgnoredWithoutCrash()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
SimulateIpcMessage(string.Empty);
|
||||
Assert.AreEqual(0, vm.AppsListed.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void DisposeViewModel_SingleCall_CompletesWithoutException()
|
||||
{
|
||||
var vm = new MainViewModel();
|
||||
vm.Dispose();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void DisposeViewModel_MultipleCalls_RemainsIdempotent()
|
||||
{
|
||||
var vm = new MainViewModel();
|
||||
vm.Dispose();
|
||||
vm.Dispose();
|
||||
}
|
||||
|
||||
private static void SimulateIpcMessage(string message)
|
||||
{
|
||||
App.IPCMessageReceivedCallback?.Invoke(message);
|
||||
}
|
||||
|
||||
private static string CreateIpcMessage(params (string Name, string Path, LaunchingState State)[] apps)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(@"{ ""processId"": 1, ""apps"": { ""appLaunchInfos"": [");
|
||||
|
||||
for (int i = 0; i < apps.Length; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
sb.Append(',');
|
||||
}
|
||||
|
||||
var (name, path, state) = apps[i];
|
||||
string escapedPath = path.Replace(@"\", @"\\");
|
||||
string appJson = string.Create(CultureInfo.InvariantCulture, $@"{{""application"": {{""application"": ""{name}"",""application-path"": ""{escapedPath}"",""title"": """",""package-full-name"": """",""app-user-model-id"": """",""pwa-app-id"": """",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": {{ ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 }},""monitor"": 0}},""state"": {(int)state}}}");
|
||||
sb.Append(appJson);
|
||||
}
|
||||
|
||||
sb.Append("]}}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/modules/Workspaces/WorkspacesLauncherUI.UnitTests/README.md
Normal file
105
src/modules/Workspaces/WorkspacesLauncherUI.UnitTests/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# WorkspacesLauncherUI Unit Tests
|
||||
|
||||
Unit tests for the Workspaces Launcher UI (WinUI 3). These validate the data layer, ViewModel, and display logic that drives the workspace launch progress window.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Visual Studio 2022 17.4+ or Visual Studio 2026
|
||||
- .NET SDK (see `global.json` in repo root)
|
||||
- Submodules initialized: `git submodule update --init --recursive`
|
||||
|
||||
## Build
|
||||
|
||||
From this directory:
|
||||
|
||||
```powershell
|
||||
# Quick build (auto-detects platform)
|
||||
& "$env:RepoRoot\tools\build\build.cmd"
|
||||
|
||||
# Or with explicit options
|
||||
& "$env:RepoRoot\tools\build\build.cmd" -Platform arm64 -Configuration Debug
|
||||
```
|
||||
|
||||
If you get NuGet restore errors on first build:
|
||||
|
||||
```powershell
|
||||
& "$env:RepoRoot\tools\build\build-essentials.cmd"
|
||||
```
|
||||
|
||||
## Run Tests
|
||||
|
||||
### Option 1: dotnet test (recommended for CI)
|
||||
|
||||
```powershell
|
||||
dotnet test "<output-dir>\tests\WorkspacesLauncherUI.Tests\PowerToys.WorkspacesLauncherUI.Tests.dll" --verbosity normal
|
||||
```
|
||||
|
||||
The output directory depends on your platform/config. For arm64 Debug:
|
||||
|
||||
```powershell
|
||||
dotnet test "arm64\Debug\tests\WorkspacesLauncherUI.Tests\PowerToys.WorkspacesLauncherUI.Tests.dll" --verbosity normal
|
||||
```
|
||||
|
||||
### Option 2: Visual Studio Test Explorer
|
||||
|
||||
1. Open `PowerToys.slnx` in Visual Studio
|
||||
2. Build the `WorkspacesLauncherUI.UnitTests` project
|
||||
3. Open Test Explorer (`Ctrl+E, T`)
|
||||
4. Run all tests in `PowerToys.WorkspacesLauncherUI.Tests`
|
||||
|
||||
### Option 3: Filter by category
|
||||
|
||||
```powershell
|
||||
dotnet test <dll-path> --filter "TestCategory=Scenario"
|
||||
dotnet test <dll-path> --filter "TestCategory=Deserialization"
|
||||
dotnet test <dll-path> --filter "TestCategory=ViewModel"
|
||||
dotnet test <dll-path> --filter "TestCategory=Model"
|
||||
dotnet test <dll-path> --filter "TestCategory=Serialization"
|
||||
dotnet test <dll-path> --filter "TestCategory=DataModel"
|
||||
dotnet test <dll-path> --filter "TestCategory=Converter"
|
||||
```
|
||||
|
||||
### Generate TRX Report
|
||||
|
||||
```powershell
|
||||
dotnet test <dll-path> --logger "trx;LogFileName=TestResults.trx"
|
||||
```
|
||||
|
||||
Report saved to `TestResults/TestResults.trx`.
|
||||
|
||||
## Test Categories
|
||||
|
||||
| Category | File | What It Validates |
|
||||
|----------|------|-------------------|
|
||||
| `Deserialization` | `IpcMessageDeserializationTests.cs` | C++ launcher engine JSON → C# data models |
|
||||
| `ViewModel` | `LauncherViewModelStateManagementTests.cs` | IPC callback → ObservableCollection pipeline |
|
||||
| `Model` | `LaunchStatusDisplayLogicTests.cs` | Spinner/glyph/color for each launch state |
|
||||
| `Scenario` | `UserWorkflowIntegrationTests.cs` | Full user workflows (launch, cancel, fail) |
|
||||
| `Serialization` | `IpcJsonPropertyNamingTests.cs` | JSON key names match C++ IPC protocol |
|
||||
| `DataModel` | `WindowPositionDataTests.cs` | Window coordinates and equality |
|
||||
| `DataModel` | `ApplicationDataModelTests.cs` | All application fields |
|
||||
| `DataModel` | `LaunchStateEnumContractTests.cs` | Enum integers match `LaunchingStateEnum.h` |
|
||||
| `Converter` | `StatusIndicatorVisibilityTests.cs` | Loading → Visibility toggle |
|
||||
|
||||
## When to Run
|
||||
|
||||
- **After IPC contract changes**: Deserialization + Serialization categories
|
||||
- **After UI state changes**: Model + ViewModel categories
|
||||
- **After dependency updates**: All tests to verify no regressions
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
Follow the naming convention: `{WhatIsUnderTest}_{GivenCondition}_{ExpectedBehavior}`
|
||||
|
||||
Example:
|
||||
```csharp
|
||||
[TestMethod]
|
||||
[TestCategory("ViewModel")]
|
||||
public void ReceiveIpcMessage_NewFieldAdded_DeserializesWithoutBreakingExistingFields()
|
||||
```
|
||||
|
||||
## Note on Color Assertions
|
||||
|
||||
Color tests use `AppLaunching.StateColorValue` (returns `Windows.UI.Color`) instead of
|
||||
`StateColor` (returns `SolidColorBrush`) because WinUI brush creation requires a UI thread.
|
||||
The `StateColorValue` property exposes the same ARGB values for headless test validation.
|
||||
@@ -0,0 +1,343 @@
|
||||
// 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.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using WorkspacesLauncherUI.Data;
|
||||
using WorkspacesLauncherUI.Models;
|
||||
using WorkspacesLauncherUI.ViewModels;
|
||||
|
||||
namespace WorkspacesLauncherUI.UnitTests
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end scenario tests that simulate complete user workflows
|
||||
/// through the Launcher UI. These verify the full pipeline:
|
||||
/// IPC JSON message → Deserialization → ViewModel → Model properties.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class UserWorkflowIntegrationTests
|
||||
{
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_ThreeApps_AllProgressFromWaitingToSuccess()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
1234,
|
||||
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.Waiting),
|
||||
App("Windows Terminal", @"C:\wt.exe", LaunchingState.Waiting),
|
||||
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.Waiting)));
|
||||
|
||||
Assert.AreEqual(3, vm.AppsListed.Count);
|
||||
Assert.IsTrue(vm.AppsListed.All(a => a.Loading), "All apps should show loading spinner initially");
|
||||
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
1234,
|
||||
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.Launched),
|
||||
App("Windows Terminal", @"C:\wt.exe", LaunchingState.Waiting),
|
||||
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.Waiting)));
|
||||
|
||||
Assert.IsTrue(vm.AppsListed[0].Loading, "Launched but not yet moved — still loading");
|
||||
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
1234,
|
||||
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.LaunchedAndMoved),
|
||||
App("Windows Terminal", @"C:\wt.exe", LaunchingState.Launched),
|
||||
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.Waiting)));
|
||||
|
||||
Assert.IsFalse(vm.AppsListed[0].Loading, "Moved app should stop loading");
|
||||
Assert.AreEqual("\U0000F78C", vm.AppsListed[0].StateGlyph, "Moved app should show checkmark");
|
||||
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
1234,
|
||||
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.LaunchedAndMoved),
|
||||
App("Windows Terminal", @"C:\wt.exe", LaunchingState.LaunchedAndMoved),
|
||||
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.LaunchedAndMoved)));
|
||||
|
||||
Assert.IsTrue(vm.AppsListed.All(a => !a.Loading), "All apps should stop loading");
|
||||
Assert.IsTrue(vm.AppsListed.All(a => a.StateGlyph == "\U0000F78C"), "All apps should show checkmark");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_OneAppMissing_FailedShowsRedOthersShowGreen()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
1234,
|
||||
App("Notepad", @"C:\Windows\notepad.exe", LaunchingState.LaunchedAndMoved),
|
||||
App("Missing App", @"C:\nonexistent\app.exe", LaunchingState.Failed),
|
||||
App("Calculator", @"C:\Windows\calc.exe", LaunchingState.LaunchedAndMoved)));
|
||||
|
||||
Assert.IsFalse(vm.AppsListed[0].Loading);
|
||||
Assert.AreEqual("\U0000F78C", vm.AppsListed[0].StateGlyph);
|
||||
|
||||
Assert.IsFalse(vm.AppsListed[1].Loading);
|
||||
Assert.AreEqual("\U0000EF2C", vm.AppsListed[1].StateGlyph);
|
||||
var color = vm.AppsListed[1].StateColorValue;
|
||||
Assert.AreEqual(254, color.R);
|
||||
|
||||
Assert.AreEqual("\U0000F78C", vm.AppsListed[2].StateGlyph);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserCancelsLaunch_MidProgress_PartialAppsShowCanceledState()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
5678,
|
||||
App("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved),
|
||||
App("App2", @"C:\app2.exe", LaunchingState.Canceled),
|
||||
App("App3", @"C:\app3.exe", LaunchingState.Canceled)));
|
||||
|
||||
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
|
||||
Assert.AreEqual(LaunchingState.Canceled, vm.AppsListed[1].LaunchState);
|
||||
Assert.AreEqual(LaunchingState.Canceled, vm.AppsListed[2].LaunchState);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_SingleApp_CompletesFullLifecycle()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
100,
|
||||
App("Notepad", @"C:\Windows\System32\notepad.exe", LaunchingState.Waiting)));
|
||||
|
||||
Assert.AreEqual(1, vm.AppsListed.Count);
|
||||
Assert.AreEqual("Notepad", vm.AppsListed[0].Name);
|
||||
Assert.IsTrue(vm.AppsListed[0].Loading);
|
||||
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
100,
|
||||
App("Notepad", @"C:\Windows\System32\notepad.exe", LaunchingState.LaunchedAndMoved)));
|
||||
|
||||
Assert.IsFalse(vm.AppsListed[0].Loading);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_ChromeAndEdgePwa_PwaIdsPreserved()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
SimulateIpcMessage(BuildMessageFull(
|
||||
300,
|
||||
AppFull("Gmail", @"C:\chrome.exe", string.Empty, string.Empty, "fmgjjmmmlfnkbppncijlocphclkkleod", LaunchingState.LaunchedAndMoved),
|
||||
AppFull("Teams", @"C:\edge.exe", string.Empty, string.Empty, "cifhbcnohmdccbgoicgdjpfamggdegmo", LaunchingState.Launched)));
|
||||
|
||||
Assert.AreEqual("fmgjjmmmlfnkbppncijlocphclkkleod", vm.AppsListed[0].PwaAppId);
|
||||
Assert.AreEqual("cifhbcnohmdccbgoicgdjpfamggdegmo", vm.AppsListed[1].PwaAppId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_AdminApp_ElevatedFlagPreservedInUi()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
string message = @"{
|
||||
""processId"": 400,
|
||||
""apps"": {
|
||||
""appLaunchInfos"": [
|
||||
{
|
||||
""application"": {
|
||||
""application"": ""Command Prompt (Admin)"",
|
||||
""application-path"": ""C:\\Windows\\System32\\cmd.exe"",
|
||||
""title"": ""Administrator: Command Prompt"",
|
||||
""package-full-name"": """",
|
||||
""app-user-model-id"": """",
|
||||
""pwa-app-id"": """",
|
||||
""command-line-arguments"": """",
|
||||
""is-elevated"": true,
|
||||
""can-launch-elevated"": true,
|
||||
""minimized"": false,
|
||||
""maximized"": false,
|
||||
""position"": { ""X"": 0, ""Y"": 0, ""width"": 800, ""height"": 600 },
|
||||
""monitor"": 0
|
||||
},
|
||||
""state"": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}";
|
||||
|
||||
SimulateIpcMessage(message);
|
||||
Assert.AreEqual("Command Prompt (Admin)", vm.AppsListed[0].Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_FifteenApps_AllAppsDisplayedWithLoadingState()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
var apps = new (string Name, string Path, LaunchingState State)[15];
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
apps[i] = ($"App {i}", $@"C:\app{i}.exe", LaunchingState.Waiting);
|
||||
}
|
||||
|
||||
SimulateIpcMessage(BuildMessage(500, apps));
|
||||
|
||||
Assert.AreEqual(15, vm.AppsListed.Count);
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
Assert.AreEqual($"App {i}", vm.AppsListed[i].Name);
|
||||
Assert.IsTrue(vm.AppsListed[i].Loading);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_AllAppsMissing_AllShowRedErrorState()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
800,
|
||||
App("App1", @"C:\missing1.exe", LaunchingState.Failed),
|
||||
App("App2", @"C:\missing2.exe", LaunchingState.Failed)));
|
||||
|
||||
Assert.IsTrue(vm.AppsListed.All(a => !a.Loading), "Failed apps should not show loading");
|
||||
Assert.IsTrue(vm.AppsListed.All(a => a.StateGlyph == "\U0000EF2C"), "Failed apps should show error glyph");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_UwpStoreApp_PackageFieldsMappedToUi()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
SimulateIpcMessage(BuildMessageFull(
|
||||
900,
|
||||
AppFull(
|
||||
"Windows Settings",
|
||||
@"C:\Program Files\WindowsApps\windows.immersivecontrolpanel\SystemSettings.exe",
|
||||
"windows.immersivecontrolpanel_10.0.0.0_neutral_cw5n1h2txyewy",
|
||||
"windows.immersivecontrolpanel_cw5n1h2txyewy!microsoft.windows.immersivecontrolpanel",
|
||||
string.Empty,
|
||||
LaunchingState.LaunchedAndMoved)));
|
||||
|
||||
Assert.AreEqual("Windows Settings", vm.AppsListed[0].Name);
|
||||
Assert.AreEqual("windows.immersivecontrolpanel_10.0.0.0_neutral_cw5n1h2txyewy", vm.AppsListed[0].PackagedName);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_RapidIpcUpdates_FinalStateIsDisplayed()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
for (int i = 0; i <= 4; i++)
|
||||
{
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
1000,
|
||||
App("App", @"C:\app.exe", (LaunchingState)Math.Min(i, 2))));
|
||||
}
|
||||
|
||||
Assert.AreEqual(1, vm.AppsListed.Count);
|
||||
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_Win32AndPackagedAndPwa_AllTypesCoexistInList()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
|
||||
SimulateIpcMessage(BuildMessageFull(
|
||||
1100,
|
||||
AppFull("Notepad", @"C:\Windows\notepad.exe", string.Empty, string.Empty, string.Empty, LaunchingState.LaunchedAndMoved),
|
||||
AppFull("Terminal", @"C:\wt.exe", "Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe", "Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", string.Empty, LaunchingState.LaunchedAndMoved),
|
||||
AppFull("Outlook", @"C:\edge.exe", string.Empty, string.Empty, "pwa_outlook_id", LaunchingState.Launched)));
|
||||
|
||||
Assert.AreEqual(3, vm.AppsListed.Count);
|
||||
Assert.AreEqual(string.Empty, vm.AppsListed[0].PwaAppId);
|
||||
Assert.AreEqual("Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe", vm.AppsListed[1].PackagedName);
|
||||
Assert.AreEqual("pwa_outlook_id", vm.AppsListed[2].PwaAppId);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("Scenario")]
|
||||
public void UserLaunchesWorkspace_FiveUpdates_UiRefreshedOnEveryIpcMessage()
|
||||
{
|
||||
using var vm = new MainViewModel();
|
||||
int fireCount = 0;
|
||||
|
||||
vm.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == "AppsListed")
|
||||
{
|
||||
fireCount++;
|
||||
}
|
||||
};
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
SimulateIpcMessage(BuildMessage(
|
||||
1200,
|
||||
App("App", @"C:\app.exe", (LaunchingState)Math.Min(i, 2))));
|
||||
}
|
||||
|
||||
Assert.AreEqual(5, fireCount, "PropertyChanged should fire once per IPC message");
|
||||
}
|
||||
|
||||
private static (string Name, string Path, LaunchingState State) App(string name, string path, LaunchingState state)
|
||||
{
|
||||
return (name, path, state);
|
||||
}
|
||||
|
||||
private static (string Name, string Path, string PackageFullName, string Aumid, string PwaAppId, LaunchingState State) AppFull(
|
||||
string name, string path, string packageFullName, string aumid, string pwaAppId, LaunchingState state)
|
||||
{
|
||||
return (name, path, packageFullName, aumid, pwaAppId, state);
|
||||
}
|
||||
|
||||
private static void SimulateIpcMessage(string message)
|
||||
{
|
||||
WorkspacesLauncherUI.App.IPCMessageReceivedCallback?.Invoke(message);
|
||||
}
|
||||
|
||||
private static string BuildMessage(
|
||||
int processId,
|
||||
params (string Name, string Path, LaunchingState State)[] apps)
|
||||
{
|
||||
var fullApps = apps.Select(a => (a.Name, a.Path, string.Empty, string.Empty, string.Empty, a.State)).ToArray();
|
||||
return BuildMessageFull(processId, fullApps);
|
||||
}
|
||||
|
||||
private static string BuildMessageFull(
|
||||
int processId,
|
||||
params (string Name, string Path, string PackageFullName, string Aumid, string PwaAppId, LaunchingState State)[] apps)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CultureInfo.InvariantCulture, $@"{{ ""processId"": {processId}, ""apps"": {{ ""appLaunchInfos"": [");
|
||||
|
||||
for (int i = 0; i < apps.Length; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
sb.Append(',');
|
||||
}
|
||||
|
||||
var (name, path, packageFullName, aumid, pwaAppId, state) = apps[i];
|
||||
string escapedPath = path.Replace(@"\", @"\\");
|
||||
string appJson = string.Create(CultureInfo.InvariantCulture, $@"{{""application"": {{""application"": ""{name}"",""application-path"": ""{escapedPath}"",""title"": """",""package-full-name"": ""{packageFullName}"",""app-user-model-id"": ""{aumid}"",""pwa-app-id"": ""{pwaAppId}"",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": {{ ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 }},""monitor"": 0}},""state"": {(int)state}}}");
|
||||
sb.Append(appJson);
|
||||
}
|
||||
|
||||
sb.Append("]}}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using WorkspacesLauncherUI.Data;
|
||||
|
||||
namespace WorkspacesLauncherUI.UnitTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for PositionWrapper struct equality and operator behavior.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class WindowPositionDataTests
|
||||
{
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionEquality_IdenticalCoordinates_ReturnsTrue()
|
||||
{
|
||||
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
|
||||
var pos2 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
|
||||
Assert.IsTrue(pos1 == pos2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionEquality_DifferentXCoordinate_ReturnsFalse()
|
||||
{
|
||||
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
|
||||
var pos2 = new PositionWrapper { X = 101, Y = 200, Width = 800, Height = 600 };
|
||||
Assert.IsFalse(pos1 == pos2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionEquality_DifferentYCoordinate_ReturnsFalse()
|
||||
{
|
||||
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
|
||||
var pos2 = new PositionWrapper { X = 100, Y = 201, Width = 800, Height = 600 };
|
||||
Assert.IsFalse(pos1 == pos2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionEquality_DifferentWidth_ReturnsFalse()
|
||||
{
|
||||
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
|
||||
var pos2 = new PositionWrapper { X = 100, Y = 200, Width = 801, Height = 600 };
|
||||
Assert.IsFalse(pos1 == pos2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionEquality_DifferentHeight_ReturnsFalse()
|
||||
{
|
||||
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
|
||||
var pos2 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 601 };
|
||||
Assert.IsFalse(pos1 == pos2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionInequality_DifferentCoordinates_ReturnsTrue()
|
||||
{
|
||||
var pos1 = new PositionWrapper { X = 0, Y = 0, Width = 1920, Height = 1080 };
|
||||
var pos2 = new PositionWrapper { X = 960, Y = 0, Width = 960, Height = 1080 };
|
||||
Assert.IsTrue(pos1 != pos2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionInequality_IdenticalCoordinates_ReturnsFalse()
|
||||
{
|
||||
var pos1 = new PositionWrapper { X = 0, Y = 0, Width = 1920, Height = 1080 };
|
||||
var pos2 = new PositionWrapper { X = 0, Y = 0, Width = 1920, Height = 1080 };
|
||||
Assert.IsFalse(pos1 != pos2);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionEquals_BoxedIdenticalValues_ReturnsTrue()
|
||||
{
|
||||
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
|
||||
object pos2 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
|
||||
Assert.IsTrue(pos1.Equals(pos2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionEquals_NullComparison_ReturnsFalse()
|
||||
{
|
||||
var pos = new PositionWrapper { X = 0, Y = 0, Width = 100, Height = 100 };
|
||||
Assert.IsFalse(pos.Equals(null));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void PositionEquals_DifferentObjectType_ReturnsFalse()
|
||||
{
|
||||
var pos = new PositionWrapper { X = 0, Y = 0, Width = 100, Height = 100 };
|
||||
Assert.IsFalse(pos.Equals("not a position"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void WindowPosition_LeftOfPrimaryMonitor_StoresNegativeCoordinates()
|
||||
{
|
||||
var pos = new PositionWrapper { X = -1920, Y = -200, Width = 1920, Height = 1080 };
|
||||
Assert.AreEqual(-1920, pos.X);
|
||||
Assert.AreEqual(-200, pos.Y);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void WindowPosition_AllZeroValues_IsValidState()
|
||||
{
|
||||
var pos = new PositionWrapper { X = 0, Y = 0, Width = 0, Height = 0 };
|
||||
Assert.AreEqual(0, pos.X);
|
||||
Assert.AreEqual(0, pos.Y);
|
||||
Assert.AreEqual(0, pos.Width);
|
||||
Assert.AreEqual(0, pos.Height);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void WindowPosition_FourthMonitor4K_StoresLargeCoordinates()
|
||||
{
|
||||
var pos = new PositionWrapper { X = 11520, Y = 0, Width = 3840, Height = 2160 };
|
||||
Assert.AreEqual(11520, pos.X);
|
||||
Assert.AreEqual(3840, pos.Width);
|
||||
Assert.AreEqual(2160, pos.Height);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void WindowPosition_DefaultStruct_AllFieldsAreZero()
|
||||
{
|
||||
PositionWrapper pos = default;
|
||||
Assert.AreEqual(0, pos.X);
|
||||
Assert.AreEqual(0, pos.Y);
|
||||
Assert.AreEqual(0, pos.Width);
|
||||
Assert.AreEqual(0, pos.Height);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DataModel")]
|
||||
public void WindowPosition_TwoDefaultStructs_AreConsideredEqual()
|
||||
{
|
||||
PositionWrapper pos1 = default;
|
||||
PositionWrapper pos2 = default;
|
||||
Assert.IsTrue(pos1 == pos2);
|
||||
Assert.IsTrue(pos1.Equals(pos2));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\WorkspacesLauncherUI.Tests\</OutputPath>
|
||||
<RootNamespace>WorkspacesLauncherUI.UnitTests</RootNamespace>
|
||||
<AssemblyName>PowerToys.WorkspacesLauncherUI.Tests</AssemblyName>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
|
||||
<ProjectReference Include="..\WorkspacesLauncherUI.WinUI\WorkspacesLauncherUI.WinUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
|
||||
namespace WorkspacesLauncherUI.Helpers
|
||||
{
|
||||
internal static class IconHelper
|
||||
{
|
||||
public static BitmapImage TryGetExecutableIcon(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using Icon icon = Icon.ExtractAssociatedIcon(path);
|
||||
if (icon is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using Bitmap bitmap = icon.ToBitmap();
|
||||
using MemoryStream stream = new();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
stream.Position = 0;
|
||||
|
||||
BitmapImage bitmapImage = new();
|
||||
bitmapImage.SetSource(stream.AsRandomAccessStream());
|
||||
return bitmapImage;
|
||||
}
|
||||
catch (Exception ex) when (ex is FileNotFoundException
|
||||
or UnauthorizedAccessException
|
||||
or Win32Exception
|
||||
or ArgumentException
|
||||
or IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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 ManagedCommon;
|
||||
using Microsoft.Windows.ApplicationModel.Resources;
|
||||
|
||||
namespace WorkspacesLauncherUI
|
||||
{
|
||||
internal static class ResourceLoaderInstance
|
||||
{
|
||||
private static ResourceLoader _resourceLoader;
|
||||
|
||||
internal static ResourceLoader ResourceLoader
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_resourceLoader == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_resourceLoader = new ResourceLoader("PowerToys.WorkspacesLauncherUI.pri");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to load ResourceLoader: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return _resourceLoader;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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 CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
|
||||
using WorkspacesLauncherUI.Data;
|
||||
|
||||
namespace WorkspacesLauncherUI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Model representing an application's launch status in the Launcher UI.
|
||||
/// Drives the display of the spinner (Loading), checkmark/X glyph (StateGlyph),
|
||||
/// and color (StateColor) for each app row.
|
||||
/// </summary>
|
||||
public partial class AppLaunching : ObservableObject
|
||||
{
|
||||
public bool Loading => LaunchState == LaunchingState.Waiting || LaunchState == LaunchingState.Launched;
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public string AppPath { get; set; }
|
||||
|
||||
public BitmapImage IconImage { get; set; }
|
||||
|
||||
public string PackagedName { get; set; }
|
||||
|
||||
public string Aumid { get; set; }
|
||||
|
||||
public string PwaAppId { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(Loading))]
|
||||
[NotifyPropertyChangedFor(nameof(StateGlyph))]
|
||||
[NotifyPropertyChangedFor(nameof(StateColor))]
|
||||
[NotifyPropertyChangedFor(nameof(StateColorValue))]
|
||||
private LaunchingState _launchState;
|
||||
|
||||
partial void OnLaunchStateChanged(LaunchingState value)
|
||||
{
|
||||
_stateColorBrush = null;
|
||||
}
|
||||
|
||||
public string StateGlyph
|
||||
{
|
||||
get => LaunchState switch
|
||||
{
|
||||
LaunchingState.LaunchedAndMoved => "\U0000F78C",
|
||||
LaunchingState.Failed => "\U0000EF2C",
|
||||
_ => "\U0000EF2C",
|
||||
};
|
||||
}
|
||||
|
||||
private SolidColorBrush _stateColorBrush;
|
||||
|
||||
public Brush StateColor
|
||||
{
|
||||
get => _stateColorBrush ??= new SolidColorBrush(StateColorValue);
|
||||
}
|
||||
|
||||
public Windows.UI.Color StateColorValue
|
||||
{
|
||||
get => LaunchState switch
|
||||
{
|
||||
LaunchingState.LaunchedAndMoved => Windows.UI.Color.FromArgb(255, 0, 128, 0),
|
||||
LaunchingState.Failed => Windows.UI.Color.FromArgb(255, 254, 0, 0),
|
||||
_ => Windows.UI.Color.FromArgb(255, 254, 0, 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/modules/Workspaces/WorkspacesLauncherUI.WinUI/Program.cs
Normal file
46
src/modules/Workspaces/WorkspacesLauncherUI.WinUI/Program.cs
Normal file
@@ -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 System;
|
||||
using System.Threading;
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.UI.Dispatching;
|
||||
|
||||
namespace WorkspacesLauncherUI
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
Logger.InitializeLogger("\\Workspaces\\WorkspacesLauncherUI");
|
||||
|
||||
WinRT.ComWrappersSupport.InitializeComWrappers();
|
||||
|
||||
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredWorkspacesEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
|
||||
{
|
||||
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
|
||||
return;
|
||||
}
|
||||
|
||||
const string mutexName = "Local\\PowerToys_Workspaces_LauncherUI_InstanceMutex";
|
||||
bool createdNew;
|
||||
using var mutex = new Mutex(true, mutexName, out createdNew);
|
||||
|
||||
if (!createdNew)
|
||||
{
|
||||
Logger.LogWarning("Another instance of Workspaces Launcher UI is already running. Exiting this instance.");
|
||||
return;
|
||||
}
|
||||
|
||||
Microsoft.UI.Xaml.Application.Start((p) =>
|
||||
{
|
||||
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
|
||||
SynchronizationContext.SetSynchronizationContext(context);
|
||||
_ = new App();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<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" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
</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="CancelButton.Content" xml:space="preserve">
|
||||
<value>Cancel launch</value>
|
||||
</data>
|
||||
<data name="CancelButton.AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Cancel launch</value>
|
||||
</data>
|
||||
<data name="DismissButton.Content" xml:space="preserve">
|
||||
<value>Dismiss</value>
|
||||
</data>
|
||||
<data name="DismissButton.AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Dismiss</value>
|
||||
</data>
|
||||
<data name="LauncherWindowTitle" xml:space="preserve">
|
||||
<value>Your workspace is launching. Waiting on ...</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -5,35 +5,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
using ManagedCommon;
|
||||
using WorkspacesCsharpLibrary;
|
||||
using WorkspacesLauncherUI.Data;
|
||||
using WorkspacesLauncherUI.Helpers;
|
||||
using WorkspacesLauncherUI.Models;
|
||||
|
||||
namespace WorkspacesLauncherUI.ViewModels
|
||||
{
|
||||
public class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
public partial class MainViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
public ObservableCollection<AppLaunching> AppsListed { get; set; } = new ObservableCollection<AppLaunching>();
|
||||
private readonly PwaHelper _pwaHelper;
|
||||
private bool _isDisposed;
|
||||
|
||||
private StatusWindow _snapshotWindow;
|
||||
private int launcherProcessID;
|
||||
private PwaHelper _pwaHelper;
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
public void OnPropertyChanged(PropertyChangedEventArgs e)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, e);
|
||||
}
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<AppLaunching> _appsListed = new ObservableCollection<AppLaunching>();
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
_pwaHelper = new PwaHelper();
|
||||
|
||||
// receive IPC Message
|
||||
App.IPCMessageReceivedCallback = (string msg) =>
|
||||
{
|
||||
try
|
||||
@@ -51,7 +46,6 @@ namespace WorkspacesLauncherUI.ViewModels
|
||||
|
||||
private void HandleAppLaunchingState(AppLaunchData.AppLaunchDataWrapper appLaunchData)
|
||||
{
|
||||
launcherProcessID = appLaunchData.LauncherProcessID;
|
||||
List<AppLaunching> appLaunchingList = new List<AppLaunching>();
|
||||
foreach (var app in appLaunchData.AppLaunchInfos.AppLaunchInfoList)
|
||||
{
|
||||
@@ -59,6 +53,7 @@ namespace WorkspacesLauncherUI.ViewModels
|
||||
{
|
||||
Name = app.Application.Application,
|
||||
AppPath = app.Application.ApplicationPath,
|
||||
IconImage = IconHelper.TryGetExecutableIcon(app.Application.ApplicationPath),
|
||||
PackagedName = app.Application.PackageFullName,
|
||||
Aumid = app.Application.AppUserModelId,
|
||||
PwaAppId = app.Application.PwaAppId,
|
||||
@@ -67,30 +62,28 @@ namespace WorkspacesLauncherUI.ViewModels
|
||||
}
|
||||
|
||||
AppsListed = new ObservableCollection<AppLaunching>(appLaunchingList);
|
||||
OnPropertyChanged(new PropertyChangedEventArgs(nameof(AppsListed)));
|
||||
}
|
||||
|
||||
private void SelfDestroy(object source, System.Timers.ElapsedEventArgs e)
|
||||
[RelayCommand]
|
||||
private void CancelLaunch()
|
||||
{
|
||||
_snapshotWindow.Dispatcher.Invoke(() =>
|
||||
{
|
||||
_snapshotWindow.Close();
|
||||
});
|
||||
App.SendIPCMessage("cancel");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Dismiss()
|
||||
{
|
||||
// Window close is handled by the view
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_isDisposed = true;
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
internal void SetSnapshotWindow(StatusWindow snapshotWindow)
|
||||
{
|
||||
_snapshotWindow = snapshotWindow;
|
||||
}
|
||||
|
||||
internal void CancelLaunch()
|
||||
{
|
||||
App.SendIPCMessage("cancel");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Application
|
||||
x:Class="WorkspacesLauncherUI.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:WorkspacesLauncherUI">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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.Globalization;
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
using PowerToys.Interop;
|
||||
|
||||
namespace WorkspacesLauncherUI
|
||||
{
|
||||
/// <summary>
|
||||
/// WinUI 3 Application class for the Workspaces Launcher UI.
|
||||
/// Manages the IPC pipe connection to the C++ launcher engine and hosts the status window.
|
||||
/// </summary>
|
||||
public partial class App : Application, IDisposable
|
||||
{
|
||||
private StatusWindow _mainWindow;
|
||||
private TwoWayPipeMessageIPCManaged _ipcManager;
|
||||
private bool _isDisposed;
|
||||
|
||||
public static Action<string> IPCMessageReceivedCallback { get; set; }
|
||||
|
||||
public static DispatcherQueue DispatcherQueue { get; private set; }
|
||||
|
||||
public App()
|
||||
{
|
||||
string languageTag = LanguageHelper.LoadLanguage();
|
||||
if (!string.IsNullOrEmpty(languageTag))
|
||||
{
|
||||
try
|
||||
{
|
||||
Microsoft.Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = languageTag;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to set language override: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
this.InitializeComponent();
|
||||
this.UnhandledException += OnUnhandledException;
|
||||
}
|
||||
|
||||
public static void SendIPCMessage(string message)
|
||||
{
|
||||
if ((Current as App)?._ipcManager != null)
|
||||
{
|
||||
(Current as App)._ipcManager.Send(message);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
DispatcherQueue = DispatcherQueue.GetForCurrentThread();
|
||||
|
||||
_ipcManager = new TwoWayPipeMessageIPCManaged(
|
||||
"\\\\.\\pipe\\powertoys_workspaces_ui_",
|
||||
"\\\\.\\pipe\\powertoys_workspaces_launcher_ui_",
|
||||
(string message) =>
|
||||
{
|
||||
if (IPCMessageReceivedCallback != null && message.Length > 0)
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
IPCMessageReceivedCallback(message);
|
||||
});
|
||||
}
|
||||
});
|
||||
_ipcManager.Start();
|
||||
|
||||
_mainWindow = new StatusWindow();
|
||||
_mainWindow.Activate();
|
||||
}
|
||||
|
||||
private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Logger.LogError("Unhandled exception occurred", e.Exception);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
_ipcManager?.End();
|
||||
_ipcManager?.Dispose();
|
||||
_isDisposed = true;
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Page
|
||||
x:Class="WorkspacesLauncherUI.Views.StatusPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:models="using:WorkspacesLauncherUI.Models"
|
||||
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid Margin="16">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<ScrollViewer
|
||||
Grid.ColumnSpan="2"
|
||||
AutomationProperties.Name="Application launch status list"
|
||||
TabIndex="0">
|
||||
<StackPanel AutomationProperties.AccessibilityView="Content" AutomationProperties.LiveSetting="Polite">
|
||||
<ItemsControl AutomationProperties.Name="Applications" ItemsSource="{x:Bind ViewModel.AppsListed, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:AppLaunching">
|
||||
<Grid
|
||||
Margin="0,4"
|
||||
Padding="4"
|
||||
AutomationProperties.Name="{x:Bind Name, Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Image
|
||||
Width="20"
|
||||
Height="20"
|
||||
Margin="4,0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Source="{x:Bind IconImage}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Text="{x:Bind Name, Mode=OneWay}" />
|
||||
<tkcontrols:SwitchPresenter
|
||||
Grid.Column="2"
|
||||
Margin="8"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
TargetType="x:Boolean"
|
||||
Value="{x:Bind Loading, Mode=OneWay}">
|
||||
<tkcontrols:Case Value="True">
|
||||
<ProgressRing
|
||||
Width="20"
|
||||
Height="20"
|
||||
AutomationProperties.Name="Loading"
|
||||
IsActive="True" />
|
||||
</tkcontrols:Case>
|
||||
<tkcontrols:Case Value="False">
|
||||
<TextBlock
|
||||
Width="20"
|
||||
Height="20"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||
FontSize="20"
|
||||
Foreground="{x:Bind StateColor, Mode=OneWay}"
|
||||
Text="{x:Bind StateGlyph, Mode=OneWay}" />
|
||||
</tkcontrols:Case>
|
||||
</tkcontrols:SwitchPresenter>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Button
|
||||
x:Name="DismissButton"
|
||||
x:Uid="DismissButton"
|
||||
Grid.Row="1"
|
||||
Margin="0,16,4,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="DismissButton_Click"
|
||||
Style="{ThemeResource AccentButtonStyle}"
|
||||
TabIndex="1" />
|
||||
|
||||
<Button
|
||||
x:Name="CancelButton"
|
||||
x:Uid="CancelButton"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="4,16,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="CancelButton_Click"
|
||||
Command="{x:Bind ViewModel.CancelLaunchCommand}"
|
||||
TabIndex="2" />
|
||||
</Grid>
|
||||
</Page>
|
||||
@@ -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.
|
||||
|
||||
using System;
|
||||
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
using WorkspacesLauncherUI.ViewModels;
|
||||
|
||||
namespace WorkspacesLauncherUI.Views
|
||||
{
|
||||
/// <summary>
|
||||
/// Page hosting the workspace launch progress content.
|
||||
/// Displays a list of apps with their launch state (loading/success/failed).
|
||||
/// Hosted inside <see cref="StatusWindow"/> so the content can use x:Bind.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", Justification = "WinUI Page does not support IDisposable; ViewModel is disposed by the hosting window on close.")]
|
||||
public sealed partial class StatusPage : Page
|
||||
{
|
||||
public MainViewModel ViewModel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user clicks Cancel or Dismiss and the hosting window should close.
|
||||
/// </summary>
|
||||
public event EventHandler CloseRequested;
|
||||
|
||||
public StatusPage()
|
||||
{
|
||||
ViewModel = new MainViewModel();
|
||||
this.InitializeComponent();
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CloseRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void DismissButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CloseRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<winuiex:WindowEx
|
||||
x:Class="WorkspacesLauncherUI.StatusWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:views="using:WorkspacesLauncherUI.Views"
|
||||
xmlns:winuiex="using:WinUIEx"
|
||||
Title="Workspaces"
|
||||
Width="360"
|
||||
Height="360"
|
||||
IsAlwaysOnTop="True"
|
||||
IsMaximizable="False"
|
||||
IsMinimizable="False"
|
||||
IsResizable="False"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Window.SystemBackdrop>
|
||||
<MicaBackdrop />
|
||||
</Window.SystemBackdrop>
|
||||
|
||||
<Grid x:Name="RootGrid">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<TitleBar x:Name="AppTitleBar" IsTabStop="False">
|
||||
<TitleBar.IconSource>
|
||||
<ImageIconSource ImageSource="/Assets/Workspaces/Workspaces.ico" />
|
||||
</TitleBar.IconSource>
|
||||
</TitleBar>
|
||||
<views:StatusPage x:Name="StatusPageView" Grid.Row="1" />
|
||||
</Grid>
|
||||
</winuiex:WindowEx>
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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 ManagedCommon;
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
using WinUIEx;
|
||||
using WorkspacesLauncherUI.Views;
|
||||
|
||||
namespace WorkspacesLauncherUI
|
||||
{
|
||||
/// <summary>
|
||||
/// Status window showing workspace launch progress.
|
||||
/// Hosts <see cref="StatusPage"/> which owns the ViewModel and renders the app list.
|
||||
/// </summary>
|
||||
public sealed partial class StatusWindow : WindowEx
|
||||
{
|
||||
public StatusWindow()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
SetTitleBar(AppTitleBar);
|
||||
AppWindow.SetIcon("Assets/Workspaces/Workspaces.ico");
|
||||
|
||||
// Set title from resources
|
||||
string title;
|
||||
try
|
||||
{
|
||||
title = ResourceLoaderInstance.ResourceLoader?.GetString("LauncherWindowTitle") ?? "Workspaces";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to load window title resource: " + ex.Message);
|
||||
title = "Workspaces";
|
||||
}
|
||||
|
||||
this.Title = title;
|
||||
AppTitleBar.Title = title;
|
||||
|
||||
StatusPageView.CloseRequested += StatusPage_CloseRequested;
|
||||
|
||||
this.Closed += Window_Closed;
|
||||
|
||||
this.CenterOnScreen();
|
||||
}
|
||||
|
||||
private void StatusPage_CloseRequested(object sender, EventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
private void Window_Closed(object sender, WindowEventArgs args)
|
||||
{
|
||||
StatusPageView.ViewModel?.Dispose();
|
||||
(Application.Current as IDisposable)?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>WorkspacesLauncherUI</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||
<DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants>
|
||||
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
|
||||
<AssemblyName>PowerToys.WorkspacesLauncherUI</AssemblyName>
|
||||
<ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon>
|
||||
<ProjectPriFileName>PowerToys.WorkspacesLauncherUI.pri</ProjectPriFileName>
|
||||
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Page Remove="Views\App.xaml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ApplicationDefinition Include="Views\App.xaml" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info -->
|
||||
<PropertyGroup>
|
||||
<CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes>
|
||||
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
|
||||
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\Assets\**\*.*">
|
||||
<Link>Assets\Workspaces\%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
|
||||
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
|
||||
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
|
||||
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
|
||||
<PackageReference Include="System.Drawing.Common" />
|
||||
<PackageReference Include="System.IO.Abstractions" />
|
||||
<PackageReference Include="WinUIEx" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="PowerToys.WorkspacesLauncherUI.app"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 compatibility for unpackaged WinUI 3 apps -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2"/>
|
||||
</startup>
|
||||
<runtime>
|
||||
<AppContextSwitchOverrides value = "Switch.System.Windows.DoNotScaleForDpiChanges=false"/>
|
||||
</runtime>
|
||||
</configuration>
|
||||
@@ -1,57 +0,0 @@
|
||||
<Application
|
||||
x:Class="WorkspacesLauncherUI.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:WorkspacesLauncherUI"
|
||||
Exit="OnExit"
|
||||
Startup="OnStartup"
|
||||
ThemeMode="System">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<FontFamily x:Key="SymbolThemeFontFamily">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
|
||||
<Style x:Key="HeadingTextBlock" TargetType="TextBlock" />
|
||||
<Style
|
||||
x:Key="SubtleButtonStyle"
|
||||
BasedOn="{StaticResource {x:Type Button}}"
|
||||
TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border
|
||||
x:Name="Border"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="4"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
|
||||
Focusable="False"
|
||||
RecognizesAccessKey="True"
|
||||
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
|
||||
<Setter TargetName="Border" Property="BorderBrush" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
|
||||
<Setter TargetName="Border" Property="BorderBrush" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorDisabledBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -1,147 +0,0 @@
|
||||
// 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.Globalization;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
|
||||
using ManagedCommon;
|
||||
using PowerToys.Interop;
|
||||
using WorkspacesLauncherUI.ViewModels;
|
||||
|
||||
namespace WorkspacesLauncherUI
|
||||
{
|
||||
/// <summary>
|
||||
/// Interaction logic for App.xaml
|
||||
/// </summary>
|
||||
public partial class App : Application, IDisposable
|
||||
{
|
||||
private static Mutex _instanceMutex;
|
||||
|
||||
// Create an instance of the IPC wrapper.
|
||||
private static TwoWayPipeMessageIPCManaged ipcmanager;
|
||||
|
||||
private StatusWindow _mainWindow;
|
||||
|
||||
private MainViewModel _mainViewModel;
|
||||
|
||||
private bool _isDisposed;
|
||||
|
||||
public static Action<string> IPCMessageReceivedCallback { get; set; }
|
||||
|
||||
public App()
|
||||
{
|
||||
}
|
||||
|
||||
public static void SendIPCMessage(string message)
|
||||
{
|
||||
if (ipcmanager != null)
|
||||
{
|
||||
ipcmanager.Send(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStartup(object sender, StartupEventArgs e)
|
||||
{
|
||||
Logger.InitializeLogger("\\Workspaces\\WorkspacesLauncherUI");
|
||||
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
|
||||
|
||||
var languageTag = LanguageHelper.LoadLanguage();
|
||||
|
||||
if (!string.IsNullOrEmpty(languageTag))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(languageTag);
|
||||
}
|
||||
catch (CultureNotFoundException ex)
|
||||
{
|
||||
Logger.LogError("CultureNotFoundException: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
const string appName = "Local\\PowerToys_Workspaces_LauncherUI_InstanceMutex";
|
||||
bool createdNew;
|
||||
_instanceMutex = new Mutex(true, appName, out createdNew);
|
||||
if (!createdNew)
|
||||
{
|
||||
Logger.LogWarning("Another instance of Workspaces Launcher UI is already running. Exiting this instance.");
|
||||
_instanceMutex = null;
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (PowerToys.GPOWrapperProjection.GPOWrapper.GetConfiguredWorkspacesEnabledValue() == PowerToys.GPOWrapperProjection.GpoRuleConfigured.Disabled)
|
||||
{
|
||||
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
ipcmanager = new TwoWayPipeMessageIPCManaged("\\\\.\\pipe\\powertoys_workspaces_ui_", "\\\\.\\pipe\\powertoys_workspaces_launcher_ui_", (string message) =>
|
||||
{
|
||||
if (IPCMessageReceivedCallback != null && message.Length > 0)
|
||||
{
|
||||
IPCMessageReceivedCallback(message);
|
||||
}
|
||||
});
|
||||
ipcmanager.Start();
|
||||
|
||||
if (_mainViewModel == null)
|
||||
{
|
||||
_mainViewModel = new MainViewModel();
|
||||
}
|
||||
|
||||
// normal start of editor
|
||||
if (_mainWindow == null)
|
||||
{
|
||||
_mainWindow = new StatusWindow(_mainViewModel);
|
||||
}
|
||||
|
||||
// reset main window owner to keep it on the top
|
||||
_mainWindow.ShowActivated = true;
|
||||
_mainWindow.Topmost = true;
|
||||
_mainWindow.Show();
|
||||
}
|
||||
|
||||
private void OnExit(object sender, ExitEventArgs e)
|
||||
{
|
||||
if (_instanceMutex != null)
|
||||
{
|
||||
_instanceMutex.ReleaseMutex();
|
||||
}
|
||||
|
||||
Dispose();
|
||||
}
|
||||
|
||||
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
|
||||
{
|
||||
Logger.LogError("Unhandled exception occurred", args.ExceptionObject as Exception);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_isDisposed)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
ipcmanager?.End();
|
||||
ipcmanager?.Dispose();
|
||||
|
||||
_instanceMutex?.Dispose();
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace WorkspacesLauncherUI.Converters
|
||||
{
|
||||
public class BooleanToInvertedVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if ((bool)value)
|
||||
{
|
||||
return Visibility.Collapsed;
|
||||
}
|
||||
|
||||
return Visibility.Visible;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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.Windows.Automation.Peers;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace WorkspacesLauncherUI
|
||||
{
|
||||
public class HeadingTextBlock : TextBlock
|
||||
{
|
||||
protected override AutomationPeer OnCreateAutomationPeer()
|
||||
{
|
||||
return new HeadingTextBlockAutomationPeer(this);
|
||||
}
|
||||
|
||||
internal sealed class HeadingTextBlockAutomationPeer : TextBlockAutomationPeer
|
||||
{
|
||||
public HeadingTextBlockAutomationPeer(HeadingTextBlock owner)
|
||||
: base(owner)
|
||||
{
|
||||
}
|
||||
|
||||
protected override AutomationControlType GetAutomationControlTypeCore()
|
||||
{
|
||||
return AutomationControlType.Header;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ManagedCommon;
|
||||
using WorkspacesCsharpLibrary.Models;
|
||||
using WorkspacesLauncherUI.Data;
|
||||
|
||||
namespace WorkspacesLauncherUI.Models
|
||||
{
|
||||
public class AppLaunching : BaseApplication, IDisposable
|
||||
{
|
||||
public bool Loading => LaunchState == LaunchingState.Waiting || LaunchState == LaunchingState.Launched;
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public LaunchingState LaunchState { get; set; }
|
||||
|
||||
public string StateGlyph
|
||||
{
|
||||
get => LaunchState switch
|
||||
{
|
||||
LaunchingState.LaunchedAndMoved => "\U0000F78C",
|
||||
LaunchingState.Failed => "\U0000EF2C",
|
||||
_ => "\U0000EF2C",
|
||||
};
|
||||
}
|
||||
|
||||
public System.Windows.Media.Brush StateColor
|
||||
{
|
||||
get => LaunchState switch
|
||||
{
|
||||
LaunchingState.LaunchedAndMoved => new SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 0, 128, 0)),
|
||||
LaunchingState.Failed => new SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 254, 0, 0)),
|
||||
_ => new SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 254, 0, 0)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <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 WorkspacesLauncherUI.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()]
|
||||
public 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)]
|
||||
public static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("WorkspacesLauncherUI.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)]
|
||||
public static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Cancel launch.
|
||||
/// </summary>
|
||||
public static string CancelLaunch {
|
||||
get {
|
||||
return ResourceManager.GetString("CancelLaunch", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Dismiss.
|
||||
/// </summary>
|
||||
public static string Dismiss {
|
||||
get {
|
||||
return ResourceManager.GetString("Dismiss", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Your workspace is launching. Waiting on ....
|
||||
/// </summary>
|
||||
public static string LauncherWindowTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("LauncherWindowTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
<?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="CancelLaunch" xml:space="preserve">
|
||||
<value>Cancel launch</value>
|
||||
</data>
|
||||
<data name="Dismiss" xml:space="preserve">
|
||||
<value>Dismiss</value>
|
||||
</data>
|
||||
<data name="LauncherWindowTitle" xml:space="preserve">
|
||||
<value>Your workspace is launching. Waiting on ...</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,26 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <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 WorkspacesLauncherUI.Properties {
|
||||
|
||||
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.1.0.0")]
|
||||
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
|
||||
|
||||
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
|
||||
|
||||
public static Settings Default {
|
||||
get {
|
||||
return defaultInstance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<SettingsFile xmlns="uri:settings" CurrentProfile="(Default)">
|
||||
<Profiles>
|
||||
<Profile Name="(Default)" />
|
||||
</Profiles>
|
||||
<Settings />
|
||||
</SettingsFile>
|
||||
@@ -1,103 +0,0 @@
|
||||
<Window
|
||||
x:Class="WorkspacesLauncherUI.StatusWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:WorkspacesLauncherUI.Converters"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="clr-namespace:WorkspacesLauncherUI"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:props="clr-namespace:WorkspacesLauncherUI.Properties"
|
||||
Title="{x:Static props:Resources.LauncherWindowTitle}"
|
||||
Width="360"
|
||||
Height="340"
|
||||
BorderBrush="Red"
|
||||
BorderThickness="4"
|
||||
Closing="Window_Closing"
|
||||
ResizeMode="NoResize"
|
||||
Topmost="True"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
mc:Ignorable="d">
|
||||
<Window.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis" />
|
||||
<converters:BooleanToInvertedVisibilityConverter x:Key="BooleanToInvertedVisibilityConverter" />
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="4" Background="Transparent">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="1*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="1*" />
|
||||
<ColumnDefinition Width="1*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ScrollViewer Grid.ColumnSpan="2">
|
||||
<StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding AppsListed, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="auto" />
|
||||
<ColumnDefinition Width="1*" />
|
||||
<ColumnDefinition Width="auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Image
|
||||
Width="20"
|
||||
Height="20"
|
||||
Margin="10"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Source="{Binding IconBitmapImage}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
Text="{Binding Name, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<ProgressBar
|
||||
Grid.Column="2"
|
||||
Width="20"
|
||||
Height="20"
|
||||
Margin="10"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsIndeterminate="True"
|
||||
Visibility="{Binding Loading, Mode=OneWay, Converter={StaticResource BoolToVis}, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
Grid.Column="2"
|
||||
Width="20"
|
||||
Height="20"
|
||||
Margin="10"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontFamily="{DynamicResource SymbolThemeFontFamily}"
|
||||
FontSize="20"
|
||||
Foreground="{Binding StateColor}"
|
||||
Text="{Binding StateGlyph}"
|
||||
Visibility="{Binding Loading, Mode=OneWay, Converter={StaticResource BooleanToInvertedVisibilityConverter}, UpdateSourceTrigger=PropertyChanged}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<Button
|
||||
x:Name="CancelButton"
|
||||
Grid.Row="1"
|
||||
Margin="4"
|
||||
HorizontalAlignment="Stretch"
|
||||
AutomationProperties.Name="{x:Static props:Resources.CancelLaunch}"
|
||||
Click="CancelButtonClicked"
|
||||
Content="{x:Static props:Resources.CancelLaunch}" />
|
||||
<Button
|
||||
x:Name="DismissButton"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="4"
|
||||
HorizontalAlignment="Stretch"
|
||||
AutomationProperties.Name="{x:Static props:Resources.Dismiss}"
|
||||
Click="DismissButtonClicked"
|
||||
Content="{x:Static props:Resources.Dismiss}"
|
||||
Style="{DynamicResource AccentButtonStyle}" />
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -1,41 +0,0 @@
|
||||
// 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.Windows;
|
||||
|
||||
using WorkspacesLauncherUI.ViewModels;
|
||||
|
||||
namespace WorkspacesLauncherUI
|
||||
{
|
||||
/// <summary>
|
||||
/// Interaction logic for SnapshotWindow.xaml
|
||||
/// </summary>
|
||||
public partial class StatusWindow : Window
|
||||
{
|
||||
private MainViewModel _mainViewModel;
|
||||
|
||||
public StatusWindow(MainViewModel mainViewModel)
|
||||
{
|
||||
_mainViewModel = mainViewModel;
|
||||
_mainViewModel.SetSnapshotWindow(this);
|
||||
this.DataContext = _mainViewModel;
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void CancelButtonClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_mainViewModel.CancelLaunch();
|
||||
Close();
|
||||
}
|
||||
|
||||
private void DismissButtonClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyTitle>PowerToys.WorkspacesLauncherUI</AssemblyTitle>
|
||||
<AssemblyDescription>PowerToys Workspaces Launcher UI</AssemblyDescription>
|
||||
<Description>PowerToys Workspaces Launcher UI</Description>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
|
||||
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<ProjectGuid>{9C53CC25-0623-4569-95BC-B05410675EE3}</ProjectGuid>
|
||||
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AssemblyName>PowerToys.WorkspacesLauncherUI</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\Assets\**\*.*">
|
||||
<Link>Assets\Workspaces\%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<COMReference Include="IWshRuntimeLibrary">
|
||||
<WrapperTool>tlbimp</WrapperTool>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<Guid>f935dc20-1cf0-11d0-adb9-00c04fd58a0b</Guid>
|
||||
<Lcid>0</Lcid>
|
||||
<Isolated>false</Isolated>
|
||||
<EmbedInteropTypes>true</EmbedInteropTypes>
|
||||
</COMReference>
|
||||
<COMReference Include="Shell32">
|
||||
<WrapperTool>tlbimp</WrapperTool>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<Guid>50a7e9b0-70ef-11d1-b75a-00a0c90564fe</Guid>
|
||||
<Lcid>0</Lcid>
|
||||
<Isolated>false</Isolated>
|
||||
<EmbedInteropTypes>true</EmbedInteropTypes>
|
||||
</COMReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<Generator>PublicResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<None Include="app.manifest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.IO.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\GPOWrapperProjection\GPOWrapperProjection.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
|
||||
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
|
||||
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
|
||||
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Update="Properties\Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Properties\Settings.Designer.cs">
|
||||
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Settings.settings</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Properties\Settings.settings">
|
||||
<Generator>SettingsSingleFileGenerator</Generator>
|
||||
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,74 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- UAC Manifest Options
|
||||
If you want to change the Windows User Account Control level replace the
|
||||
requestedExecutionLevel node with one of the following.
|
||||
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
|
||||
|
||||
Specifying requestedExecutionLevel element will disable file and registry virtualization.
|
||||
Remove this element if your application requires this virtualization for backwards
|
||||
compatibility.
|
||||
-->
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows Vista -->
|
||||
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
|
||||
|
||||
<!-- Windows 7 -->
|
||||
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
|
||||
|
||||
<!-- Windows 8 -->
|
||||
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
|
||||
|
||||
<!-- Windows 8.1 -->
|
||||
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
|
||||
DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need
|
||||
to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should
|
||||
also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. -->
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
|
||||
<!--
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
-->
|
||||
|
||||
</assembly>
|
||||
@@ -171,6 +171,7 @@ FONT 8, "MS Shell Dlg", 400, 0, 0x1
|
||||
BEGIN
|
||||
CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12
|
||||
LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,230,26
|
||||
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,34,230,18
|
||||
LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8
|
||||
CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT
|
||||
LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,230,10
|
||||
@@ -182,8 +183,6 @@ BEGIN
|
||||
LTEXT "4.0",IDC_STATIC,190,136,12,8
|
||||
CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10
|
||||
CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10
|
||||
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,230,17
|
||||
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,230,18
|
||||
END
|
||||
|
||||
DRAW DIALOGEX 0, 0, 260, 228
|
||||
@@ -315,26 +314,31 @@ BEGIN
|
||||
PUSHBUTTON "Cancel",IDCANCEL,162,142,50,14
|
||||
END
|
||||
|
||||
SNIP DIALOGEX 0, 0, 260, 80
|
||||
SNIP DIALOGEX 0, 0, 272, 105
|
||||
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
|
||||
FONT 8, "MS Shell Dlg", 400, 0, 0x1
|
||||
BEGIN
|
||||
LTEXT "Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file.",IDC_STATIC,7,7,230,19
|
||||
LTEXT "Snip Toggle:",IDC_STATIC,7,33,45,8
|
||||
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,32,80,12
|
||||
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,50,230,10
|
||||
LTEXT "Text Toggle:",IDC_STATIC,7,65,55,8
|
||||
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,63,80,12
|
||||
LTEXT "Copy a region of the screen to the clipboard, or save it to a file using the save shortcut.",IDC_STATIC,7,7,230,18
|
||||
RTEXT "Snip Toggle:",IDC_STATIC,22,33,45,8
|
||||
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,32,80,12
|
||||
RTEXT "Snip Save Toggle:",IDC_STATIC,7,49,60,8
|
||||
CONTROL "",IDC_SNIP_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,48,80,12
|
||||
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,66,230,10
|
||||
RTEXT "Text Toggle:",IDC_STATIC,12,82,55,8
|
||||
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,81,80,12
|
||||
END
|
||||
|
||||
PANORAMA DIALOGEX 0, 0, 260, 105
|
||||
PANORAMA DIALOGEX 0, 0, 260, 140
|
||||
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
|
||||
FONT 8, "MS Shell Dlg", 400, 0, 0x1
|
||||
BEGIN
|
||||
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas. Press the hotkey again or with Shift to save to a file.",IDC_STATIC,7,7,245,33
|
||||
LTEXT "Panorama Toggle:",IDC_STATIC,7,74,63,8
|
||||
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,73,72,80,12
|
||||
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,41,245,30
|
||||
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas.",IDC_STATIC,7,7,245,30
|
||||
LTEXT "Press the panorama toggle again to copy to the clipboard, or use the save shortcut to save to a file.",IDC_STATIC,7,39,245,18
|
||||
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,62,245,30
|
||||
LTEXT "Panorama Toggle:",IDC_STATIC,7,95,80,8
|
||||
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,93,80,12
|
||||
LTEXT "Panorama Save Toggle:",IDC_STATIC,7,111,80,8
|
||||
CONTROL "",IDC_SNIP_PANORAMA_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,109,80,12
|
||||
END
|
||||
|
||||
DEMOTYPE DIALOGEX 0, 0, 260, 249
|
||||
@@ -456,7 +460,9 @@ BEGIN
|
||||
"SNIP", DIALOG
|
||||
BEGIN
|
||||
LEFTMARGIN, 7
|
||||
RIGHTMARGIN, 265
|
||||
TOPMARGIN, 7
|
||||
BOTTOMMARGIN, 98
|
||||
END
|
||||
|
||||
"PANORAMA", DIALOG
|
||||
|
||||
@@ -17,7 +17,9 @@ DWORD g_BreakToggleKey = ((HOTKEYF_CONTROL) << 8)| '3';
|
||||
DWORD g_DemoTypeToggleKey = ((HOTKEYF_CONTROL) << 8) | '7';
|
||||
DWORD g_RecordToggleKey = ((HOTKEYF_CONTROL) << 8) | '5';
|
||||
DWORD g_SnipToggleKey = ((HOTKEYF_CONTROL) << 8) | '6';
|
||||
DWORD g_SnipSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '6';
|
||||
DWORD g_SnipPanoramaToggleKey = ((HOTKEYF_CONTROL) << 8) | '8';
|
||||
DWORD g_SnipPanoramaSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '8';
|
||||
DWORD g_SnipOcrToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_ALT) << 8) | '6';
|
||||
|
||||
DWORD g_ShowExpiredTime = 1;
|
||||
@@ -80,7 +82,9 @@ REG_SETTING RegSettings[] = {
|
||||
{ L"DrawToggleKey", SETTING_TYPE_DWORD, 0, &g_DrawToggleKey, static_cast<DOUBLE>(g_DrawToggleKey) },
|
||||
{ L"RecordToggleKey", SETTING_TYPE_DWORD, 0, &g_RecordToggleKey, static_cast<DOUBLE>(g_RecordToggleKey) },
|
||||
{ L"SnipToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipToggleKey, static_cast<DOUBLE>(g_SnipToggleKey) },
|
||||
{ L"SnipSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipSaveToggleKey, static_cast<DOUBLE>(g_SnipSaveToggleKey) },
|
||||
{ L"SnipPanoramaToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaToggleKey, static_cast<DOUBLE>(g_SnipPanoramaToggleKey) },
|
||||
{ L"SnipPanoramaSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaSaveToggleKey, static_cast<DOUBLE>(g_SnipPanoramaSaveToggleKey) },
|
||||
{ L"SnipOcrToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipOcrToggleKey, static_cast<DOUBLE>(g_SnipOcrToggleKey) },
|
||||
{ L"PenColor", SETTING_TYPE_DWORD, 0, &g_PenColor, static_cast<DOUBLE>(g_PenColor) },
|
||||
{ L"PenWidth", SETTING_TYPE_DWORD, 0, &g_RootPenWidth, static_cast<DOUBLE>(g_RootPenWidth) },
|
||||
|
||||
@@ -174,6 +174,8 @@ DWORD g_RecordToggleMod;
|
||||
DWORD g_SnipToggleMod;
|
||||
DWORD g_SnipPanoramaToggleMod;
|
||||
DWORD g_SnipOcrToggleMod;
|
||||
DWORD g_SnipSaveToggleMod;
|
||||
DWORD g_SnipPanoramaSaveToggleMod;
|
||||
|
||||
BOOLEAN g_ZoomOnLiveZoom = FALSE;
|
||||
DWORD g_PenWidth = PEN_WIDTH;
|
||||
@@ -212,7 +214,10 @@ BOOL g_RecordToggle = FALSE;
|
||||
BOOL g_RecordCropping = FALSE;
|
||||
SelectRectangle g_SelectRectangle;
|
||||
WebcamPreviewWindow g_WebcamPreview;
|
||||
// The full path of the last saved recording file.
|
||||
std::wstring g_RecordingSaveLocation;
|
||||
// The last user-chosen recording filename. Used to construct unique recording filenames.
|
||||
std::wstring g_RecordingSaveBaseFilename;
|
||||
std::wstring g_ScreenshotSaveLocation;
|
||||
winrt::IDirect3DDevice g_RecordDevice{ nullptr };
|
||||
std::shared_ptr<VideoRecordingSession> g_RecordingSession = nullptr;
|
||||
@@ -3582,12 +3587,16 @@ void RegisterAllHotkeys(HWND hWnd)
|
||||
}
|
||||
if (g_SnipToggleKey) {
|
||||
registerHotkey( SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF );
|
||||
registerHotkey( SNIP_SAVE_HOTKEY, ( g_SnipToggleMod ^ MOD_SHIFT ), g_SnipToggleKey & 0xFF );
|
||||
}
|
||||
if( g_SnipPanoramaToggleKey &&
|
||||
if (g_SnipSaveToggleKey) {
|
||||
registerHotkey( SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF);
|
||||
}
|
||||
if (g_SnipPanoramaToggleKey &&
|
||||
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) ) {
|
||||
registerHotkey( SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
|
||||
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
|
||||
}
|
||||
if (g_SnipPanoramaSaveToggleKey) {
|
||||
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF );
|
||||
}
|
||||
if (g_SnipOcrToggleKey) {
|
||||
registerHotkey( SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF );
|
||||
@@ -4816,6 +4825,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
TCHAR text[32];
|
||||
DWORD newToggleKey, newTimeout, newToggleMod, newBreakToggleKey, newDemoTypeToggleKey, newRecordToggleKey, newSnipToggleKey, newSnipPanoramaToggleKey, newSnipOcrToggleKey;
|
||||
DWORD newDrawToggleKey, newDrawToggleMod, newBreakToggleMod, newDemoTypeToggleMod, newRecordToggleMod, newSnipToggleMod, newSnipPanoramaToggleMod, newSnipOcrToggleMod;
|
||||
DWORD newSnipSaveToggleKey, newSnipSaveToggleMod;
|
||||
DWORD newSnipPanoramaSaveToggleKey, newSnipPanoramaSaveToggleMod;
|
||||
DWORD newLiveZoomToggleKey, newLiveZoomToggleMod;
|
||||
static std::vector<std::pair<std::wstring, std::wstring>> microphones;
|
||||
|
||||
@@ -5050,7 +5061,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
if( g_DemoTypeToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_SETHOTKEY, g_DemoTypeToggleKey, 0 );
|
||||
if( g_RecordToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_SETHOTKEY, g_RecordToggleKey, 0 );
|
||||
if( g_SnipToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_SETHOTKEY, g_SnipToggleKey, 0 );
|
||||
if( g_SnipSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipSaveToggleKey, 0 );
|
||||
if( g_SnipPanoramaToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaToggleKey, 0 );
|
||||
if( g_SnipPanoramaSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaSaveToggleKey, 0 );
|
||||
if( g_SnipOcrToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_SETHOTKEY, g_SnipOcrToggleKey, 0 );
|
||||
CheckDlgButton( hDlg, IDC_SHOW_TRAY_ICON,
|
||||
g_ShowTrayIcon ? BST_CHECKED: BST_UNCHECKED );
|
||||
@@ -5512,7 +5525,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
newDemoTypeToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_GETHOTKEY, 0, 0 ));
|
||||
newRecordToggleKey = static_cast<DWORD>(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_GETHOTKEY, 0, 0));
|
||||
newSnipToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
newSnipSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
newSnipPanoramaToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
newSnipPanoramaSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
newSnipOcrToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
|
||||
|
||||
newToggleMod = GetKeyMod( newToggleKey );
|
||||
@@ -5522,7 +5537,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
newDemoTypeToggleMod = GetKeyMod( newDemoTypeToggleKey );
|
||||
newRecordToggleMod = GetKeyMod(newRecordToggleKey);
|
||||
newSnipToggleMod = GetKeyMod( newSnipToggleKey );
|
||||
newSnipSaveToggleMod = GetKeyMod( newSnipSaveToggleKey );
|
||||
newSnipPanoramaToggleMod = GetKeyMod( newSnipPanoramaToggleKey );
|
||||
newSnipPanoramaSaveToggleMod = GetKeyMod( newSnipPanoramaSaveToggleKey );
|
||||
newSnipOcrToggleMod = GetKeyMod( newSnipOcrToggleKey );
|
||||
|
||||
g_SliderZoomLevel = static_cast<int>(SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_GETPOS, 0, 0 ));
|
||||
@@ -5591,25 +5608,41 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
|
||||
}
|
||||
else if (newSnipToggleKey &&
|
||||
(!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, (newSnipToggleMod ^ MOD_SHIFT), newSnipToggleKey & 0xFF))) {
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hDlg, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
|
||||
}
|
||||
else if (newSnipSaveToggleKey &&
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, newSnipSaveToggleMod, newSnipSaveToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hDlg, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
|
||||
}
|
||||
else if (newSnipPanoramaToggleKey &&
|
||||
(newSnipPanoramaToggleKey != newSnipToggleKey || newSnipPanoramaToggleMod != newSnipToggleMod) &&
|
||||
(!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, ( newSnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF))) {
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hDlg, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
|
||||
}
|
||||
else if (newSnipPanoramaSaveToggleKey &&
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, newSnipPanoramaSaveToggleMod | MOD_NOREPEAT, newSnipPanoramaSaveToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hDlg, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
UnregisterAllHotkeys(GetParent(hDlg));
|
||||
break;
|
||||
|
||||
}
|
||||
else if (newSnipOcrToggleKey &&
|
||||
!RegisterHotKey(GetParent(hDlg), SNIP_OCR_HOTKEY, newSnipOcrToggleMod, newSnipOcrToggleKey & 0xFF)) {
|
||||
@@ -5645,8 +5678,12 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
|
||||
g_RecordToggleMod = newRecordToggleMod;
|
||||
g_SnipToggleKey = newSnipToggleKey;
|
||||
g_SnipToggleMod = newSnipToggleMod;
|
||||
g_SnipSaveToggleKey = newSnipSaveToggleKey;
|
||||
g_SnipSaveToggleMod = newSnipSaveToggleMod;
|
||||
g_SnipPanoramaToggleKey = newSnipPanoramaToggleKey;
|
||||
g_SnipPanoramaToggleMod = newSnipPanoramaToggleMod;
|
||||
g_SnipPanoramaSaveToggleKey = newSnipPanoramaSaveToggleKey;
|
||||
g_SnipPanoramaSaveToggleMod = newSnipPanoramaSaveToggleMod;
|
||||
g_SnipOcrToggleKey = newSnipOcrToggleKey;
|
||||
g_SnipOcrToggleMod = newSnipOcrToggleMod;
|
||||
reg.WriteRegSettings( RegSettings );
|
||||
@@ -6737,6 +6774,45 @@ void StopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
// GetTimestampSuffix
|
||||
//
|
||||
// Returns a timestamp string for disambiguating filenames.
|
||||
// Format: " YYYY-MM-DD HHMMSS", e.g." 2025-11-02 143000".
|
||||
//
|
||||
// Used as a suffix for the default recording filename. Ensures
|
||||
// chronological name sorting in Explorer.
|
||||
//
|
||||
//----------------------------------------------------------------------------
|
||||
static std::wstring GetTimestampSuffix()
|
||||
{
|
||||
auto const now = std::chrono::system_clock::now();
|
||||
auto const in_time_t = std::chrono::system_clock::to_time_t( now );
|
||||
|
||||
std::tm buf{};
|
||||
localtime_s( &buf, &in_time_t );
|
||||
|
||||
std::wstringstream ss;
|
||||
ss << L" " << std::put_time( &buf, L"%Y-%m-%d %H%M%S" );
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
// IsDefaultRecordingFilename
|
||||
//
|
||||
// Determines if the provided filename matches the default recording name.
|
||||
// Case-insensitive comparison.
|
||||
//
|
||||
// Returns:
|
||||
// true if filename is the default; otherwise false.
|
||||
//
|
||||
//----------------------------------------------------------------------------
|
||||
static bool IsDefaultRecordingFilename(const std::wstring& filename)
|
||||
{
|
||||
return CompareStringOrdinal( DEFAULT_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL
|
||||
|| CompareStringOrdinal( DEFAULT_GIF_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL;
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
//
|
||||
@@ -6791,19 +6867,70 @@ std::wstring GetUniqueFilename(const std::wstring& lastSavePath, const wchar_t*
|
||||
//
|
||||
// GetUniqueRecordingFilename
|
||||
//
|
||||
// Gets a unique file name for recording saves, using the " (N)" suffix
|
||||
// approach so that the user can hit OK without worrying about overwriting
|
||||
// if they are making multiple recordings in one session or don't want to
|
||||
// always see an overwrite dialog or stop to clean up files.
|
||||
// Generates a unique filename to be suggested in the "Save As" recording
|
||||
// dialog, based on the user's last chosen filename and save location.
|
||||
// This allows the user to quickly save a recording without worrying about
|
||||
// manual renaming to prevent overwriting earlier recordings.
|
||||
//
|
||||
// There are two distinct behaviors based on the last used filename:
|
||||
//
|
||||
// 1. For the default filename ("Recording.mp4"):
|
||||
// Generates a more descriptive name by appending a timestamp, e.g.
|
||||
// "Recording 2025-11-03 143015.mp4". This ensures chronological sorting
|
||||
// in Explorer when ordered by name and is consistent with other tools.
|
||||
//
|
||||
// 2. For custom filenames (e.g. "Presentation.mp4"):
|
||||
// Appends a numeric suffix if the file already exists, e.g.
|
||||
// "Presentation (1).mp4", "Presentation (2).mp4", etc.
|
||||
//
|
||||
// Returns:
|
||||
// A unique filename (without folder path).
|
||||
//
|
||||
// Relies upon the global state of `g_RecordingSaveLocation` and
|
||||
// `g_RecordingSaveBaseFilename`.
|
||||
//
|
||||
//----------------------------------------------------------------------------
|
||||
auto GetUniqueRecordingFilename()
|
||||
static auto GetUniqueRecordingFilename()
|
||||
{
|
||||
const wchar_t* defaultFile = (g_RecordingFormat == RecordingFormat::GIF)
|
||||
? DEFAULT_GIF_RECORDING_FILE
|
||||
: DEFAULT_RECORDING_FILE;
|
||||
|
||||
return GetUniqueFilename(g_RecordingSaveLocation, defaultFile, FOLDERID_Videos);
|
||||
// Without a remembered filename, suggest the default name for the current format.
|
||||
std::wstring baseFilename = g_RecordingSaveBaseFilename.empty()
|
||||
? std::wstring( defaultFile )
|
||||
: g_RecordingSaveBaseFilename;
|
||||
|
||||
std::filesystem::path basePath{ baseFilename };
|
||||
|
||||
// For the default filename, append a timestamp so successive default saves stay
|
||||
// unique and sort chronologically in Explorer.
|
||||
if ( IsDefaultRecordingFilename( basePath.filename().wstring() ) )
|
||||
{
|
||||
return basePath.stem().wstring() + GetTimestampSuffix() + basePath.extension().wstring();
|
||||
}
|
||||
|
||||
// For custom filenames, append a numeric suffix to avoid collisions.
|
||||
std::filesystem::path directory;
|
||||
if ( !g_RecordingSaveLocation.empty() )
|
||||
directory = std::filesystem::path( g_RecordingSaveLocation ).parent_path();
|
||||
if ( directory.empty() )
|
||||
{
|
||||
wil::unique_cotaskmem_string folderPath;
|
||||
if ( SUCCEEDED( SHGetKnownFolderPath( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, folderPath.put() ) ) )
|
||||
directory = folderPath.get();
|
||||
}
|
||||
|
||||
std::wstring baseStem = basePath.stem().wstring();
|
||||
std::wstring baseExtension = basePath.extension().wstring();
|
||||
|
||||
std::filesystem::path testPath = directory / ( baseStem + baseExtension );
|
||||
for ( int index = 1; std::filesystem::exists( testPath ); index++ )
|
||||
{
|
||||
testPath = directory / ( baseStem + L" (" + std::to_wstring( index ) + L')' + baseExtension );
|
||||
}
|
||||
|
||||
return testPath.filename().wstring();
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------
|
||||
@@ -6835,7 +6962,7 @@ auto GetUniqueScreenshotFilename()
|
||||
//
|
||||
// StartRecordingAsync
|
||||
//
|
||||
// Starts the screen recording.
|
||||
// Initiates screen recording and handles the save dialog workflow.
|
||||
//
|
||||
//----------------------------------------------------------------------------
|
||||
winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndRecord ) try
|
||||
@@ -7080,8 +7207,30 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
|
||||
if (!finalPath.empty())
|
||||
{
|
||||
auto path = std::filesystem::path(finalPath);
|
||||
|
||||
// Remember the user's chosen filename and apply a timestamp to default
|
||||
// names so successive saves stay unique and sort chronologically.
|
||||
std::wstring filename = path.filename().wstring();
|
||||
std::wstring finalFilename = filename;
|
||||
if ( IsDefaultRecordingFilename( filename ) )
|
||||
{
|
||||
// The user accepted or re-typed the default filename. Remember it so the
|
||||
// next suggestion also uses a timestamp, and append one to this save.
|
||||
g_RecordingSaveBaseFilename = filename;
|
||||
finalFilename = path.stem().wstring() + GetTimestampSuffix() + path.extension().wstring();
|
||||
}
|
||||
else if ( CompareStringOrdinal( suggestedName.c_str(), -1, filename.c_str(), -1, TRUE ) != CSTR_EQUAL )
|
||||
{
|
||||
// The user chose their own filename instead of the suggested one. Remember
|
||||
// it so future suggestions use numeric suffixes based on this name.
|
||||
g_RecordingSaveBaseFilename = filename;
|
||||
}
|
||||
|
||||
// The path actually written to disk (with any timestamp applied).
|
||||
std::wstring savedPath = ( path.parent_path() / finalFilename ).wstring();
|
||||
|
||||
winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync(path.parent_path().c_str()) };
|
||||
destFile = co_await folder.CreateFileAsync(path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting);
|
||||
destFile = co_await folder.CreateFileAsync(finalFilename.c_str(), winrt::CreationCollisionOption::ReplaceExisting);
|
||||
|
||||
// If user trimmed, use the trimmed file
|
||||
winrt::StorageFile sourceFile = file;
|
||||
@@ -7099,8 +7248,8 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
|
||||
try { co_await file.DeleteAsync(); } catch (...) {}
|
||||
}
|
||||
|
||||
// Use finalPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
|
||||
g_RecordingSaveLocation = finalPath;
|
||||
// Use savedPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
|
||||
g_RecordingSaveLocation = savedPath;
|
||||
// Update the registry buffer and save to persist across app restarts
|
||||
wcsncpy_s(g_RecordingSaveLocationBuffer, g_RecordingSaveLocation.c_str(), _TRUNCATE);
|
||||
reg.WriteRegSettings(RegSettings);
|
||||
@@ -7600,7 +7749,9 @@ LRESULT APIENTRY MainWndProc(
|
||||
g_BreakToggleMod = GetKeyMod( g_BreakToggleKey );
|
||||
g_DemoTypeToggleMod = GetKeyMod( g_DemoTypeToggleKey );
|
||||
g_SnipToggleMod = GetKeyMod( g_SnipToggleKey );
|
||||
g_SnipSaveToggleMod = GetKeyMod( g_SnipSaveToggleKey );
|
||||
g_SnipPanoramaToggleMod = GetKeyMod( g_SnipPanoramaToggleKey );
|
||||
g_SnipPanoramaSaveToggleMod = GetKeyMod( g_SnipPanoramaSaveToggleKey );
|
||||
g_SnipOcrToggleMod = GetKeyMod( g_SnipOcrToggleKey );
|
||||
g_RecordToggleMod = GetKeyMod( g_RecordToggleKey );
|
||||
|
||||
@@ -7651,23 +7802,37 @@ LRESULT APIENTRY MainWndProc(
|
||||
|
||||
}
|
||||
else if (g_SnipToggleKey &&
|
||||
(!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))) {
|
||||
!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hWnd, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
showOptions = TRUE;
|
||||
|
||||
}
|
||||
else if (g_SnipSaveToggleKey &&
|
||||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
showOptions = TRUE;
|
||||
|
||||
}
|
||||
else if (g_SnipPanoramaToggleKey &&
|
||||
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) &&
|
||||
(!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))) {
|
||||
!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hWnd, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
showOptions = TRUE;
|
||||
|
||||
}
|
||||
else if (g_SnipPanoramaSaveToggleKey &&
|
||||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF)) {
|
||||
|
||||
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
|
||||
APPNAME, MB_ICONERROR);
|
||||
showOptions = TRUE;
|
||||
|
||||
}
|
||||
else if (g_SnipOcrToggleKey &&
|
||||
!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF)) {
|
||||
@@ -10254,7 +10419,9 @@ LRESULT APIENTRY MainWndProc(
|
||||
g_BreakToggleMod = GetKeyMod(g_BreakToggleKey);
|
||||
g_DemoTypeToggleMod = GetKeyMod(g_DemoTypeToggleKey);
|
||||
g_SnipToggleMod = GetKeyMod(g_SnipToggleKey);
|
||||
g_SnipSaveToggleMod = GetKeyMod(g_SnipSaveToggleKey);
|
||||
g_SnipPanoramaToggleMod = GetKeyMod(g_SnipPanoramaToggleKey);
|
||||
g_SnipPanoramaSaveToggleMod = GetKeyMod(g_SnipPanoramaSaveToggleKey);
|
||||
g_SnipOcrToggleMod = GetKeyMod(g_SnipOcrToggleKey);
|
||||
g_RecordToggleMod = GetKeyMod(g_RecordToggleKey);
|
||||
BOOL showOptions = FALSE;
|
||||
@@ -10317,8 +10484,7 @@ LRESULT APIENTRY MainWndProc(
|
||||
}
|
||||
if (g_SnipToggleKey)
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))
|
||||
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF))
|
||||
{
|
||||
if(!g_StartedByPowerToys)
|
||||
{
|
||||
@@ -10327,11 +10493,21 @@ LRESULT APIENTRY MainWndProc(
|
||||
showOptions = TRUE;
|
||||
}
|
||||
}
|
||||
if (g_SnipSaveToggleKey)
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF))
|
||||
{
|
||||
if(!g_StartedByPowerToys)
|
||||
{
|
||||
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.", APPNAME, MB_ICONERROR);
|
||||
}
|
||||
showOptions = TRUE;
|
||||
}
|
||||
}
|
||||
if (g_SnipPanoramaToggleKey &&
|
||||
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod))
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
|
||||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
|
||||
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
|
||||
{
|
||||
if(!g_StartedByPowerToys)
|
||||
{
|
||||
@@ -10340,6 +10516,17 @@ LRESULT APIENTRY MainWndProc(
|
||||
showOptions = TRUE;
|
||||
}
|
||||
}
|
||||
if (g_SnipPanoramaSaveToggleKey)
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF))
|
||||
{
|
||||
if(!g_StartedByPowerToys)
|
||||
{
|
||||
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.", APPNAME, MB_ICONERROR);
|
||||
}
|
||||
showOptions = TRUE;
|
||||
}
|
||||
}
|
||||
if (g_SnipOcrToggleKey)
|
||||
{
|
||||
if (!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF))
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <future>
|
||||
#include <regex>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user