mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-06 10:30:29 +02:00
Compare commits
16 Commits
user/muyua
...
yuleng/pd/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4ec92bb8f | ||
|
|
450dd5dd59 | ||
|
|
53fc27b552 | ||
|
|
0d5eb84d4f | ||
|
|
8e8e4408ae | ||
|
|
9c762c720c | ||
|
|
f8bf1be06b | ||
|
|
b0059e2c1d | ||
|
|
a81effce10 | ||
|
|
5933cb9ece | ||
|
|
bf81179b35 | ||
|
|
6e7232862f | ||
|
|
0f434b5344 | ||
|
|
d7916e0457 | ||
|
|
649a1dda15 | ||
|
|
5487807d6f |
@@ -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.
|
||||
299
doc/specs/2026-06-30-powerdisplay-vcp-code-resolution-design.md
Normal file
299
doc/specs/2026-06-30-powerdisplay-vcp-code-resolution-design.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# PowerDisplay — Per-Feature VCP Code Resolution & Persistence
|
||||
|
||||
Design spec. Date: 2026-06-30. Branch: `yuleng/powerdisplay/vcp-code-resolution/1`.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Each PowerDisplay feature (brightness, contrast, volume, color temperature, input
|
||||
source, power state) is currently bound to **exactly one hardcoded VCP code**
|
||||
(`NativeConstants.cs`: brightness `0x10`, contrast `0x12`, volume `0x62`, color
|
||||
preset `0x14`, input source `0x60`, power mode `0xD6`). `DdcCiController` uses these
|
||||
constants verbatim in its `Get*/Set*/Initialize*` methods.
|
||||
|
||||
In reality a single logical feature can map to **different VCP codes on different
|
||||
monitors**. The canonical case is brightness: most monitors expose it on `0x10`
|
||||
(Luminance), but some expose only `0x13` (Backlight Control) or `0x6B`
|
||||
(Backlight Level: White). With a single hardcoded code these monitors appear to not
|
||||
support the feature.
|
||||
|
||||
We need a mechanism where, for each feature, we define an **ordered list of candidate
|
||||
VCP codes by priority**, then **resolve** which code a given monitor actually uses,
|
||||
**persist** that decision per monitor (so we never re-probe needlessly — including a
|
||||
"checked, none supported" sentinel), and **surface** the resolved code in the
|
||||
diagnostics output.
|
||||
|
||||
## 2. Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
- A general, data-driven registry mapping each feature to an ordered candidate-code list.
|
||||
- A pure, unit-testable resolver implementing **cap-string-first, probe-as-fallback**.
|
||||
- Per-monitor persistence of the resolved map (including a "not supported" sentinel),
|
||||
reused across sessions and invalidated **only** by a user-initiated Refresh.
|
||||
- `DdcCiController` reads/writes each feature through its resolved code.
|
||||
- Resolved codes appear in the per-monitor diagnostics ("Copy Diagnostics").
|
||||
|
||||
### Non-Goals
|
||||
- Changing native P/Invoke signatures (`GetVCPFeatureAndVCPFeatureReply`, `SetVCPFeature`).
|
||||
- Reworking discrete features' value enumeration (input-source / power-state value
|
||||
lists still come from the capabilities string).
|
||||
- A user-facing per-monitor code-override UI (out of scope; the registry is the source
|
||||
of candidate lists).
|
||||
- Seeding alternate codes for features other than brightness. The mechanism is general;
|
||||
only brightness is seeded with multiple candidates now (decision: "general framework,
|
||||
brightness-only multi-candidate"). All other features keep a single-candidate list
|
||||
equal to their current constant.
|
||||
|
||||
## 3. Key Decisions (confirmed)
|
||||
|
||||
1. **Resolution model = cap-string-first, probe-as-fallback.**
|
||||
- Normal mode: resolve purely from the parsed capabilities string, in priority order.
|
||||
- Max-compatibility mode: same Phase 1; **only if** the cap string lists none of a
|
||||
feature's candidates do we actively probe candidates (read-only `GetVCP`) in
|
||||
priority order and take the first that responds with a usable value.
|
||||
2. **Brightness candidate priority = `0x10` → `0x13` → `0x6B`.** Other features:
|
||||
single-candidate (their existing constant).
|
||||
3. **Invalidation = user Refresh only.** A persisted map (code or sentinel) is reused
|
||||
indefinitely; the user-initiated Refresh clears it and forces re-resolution.
|
||||
Startup and the display-change watcher reuse persisted maps without clearing.
|
||||
- Documented consequence: a feature resolved to "not supported" via cap-string-only
|
||||
(max-compat off) will **not** be auto-re-probed when max-compat is later turned on;
|
||||
the user must press Refresh. This is the intended trade-off.
|
||||
4. **Shape = general framework, brightness-only multi-candidate** (see Non-Goals).
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
```
|
||||
PowerDisplay (app) PowerDisplay.Lib
|
||||
MainViewModel ── reads/writes persisted maps ──► MonitorStateManager (state file)
|
||||
│ push persisted maps + maxCompat ▲ persist resolved maps
|
||||
▼ (before discovery) │
|
||||
MonitorManager ──────────────────────────────► DdcCiController
|
||||
│ BuildMonitorFromPhysical
|
||||
│ caps + handle
|
||||
▼
|
||||
VcpFeatureResolver.Resolve(
|
||||
caps, maxCompat, persistedMap, probe)
|
||||
│ uses VcpFeatureRegistry
|
||||
▼
|
||||
Monitor.ResolvedVcpCodes (VcpFeatureCodeMap)
|
||||
│
|
||||
CreateMonitorInfo ──► MonitorInfo.ResolvedVcpCodes ─┘ (settings.json → Settings UI)
|
||||
│
|
||||
▼
|
||||
MonitorInfo.GetDiagnosticsAsText() (hex-only section)
|
||||
```
|
||||
|
||||
Resolution runs **inside the discovery pipeline** so each feature's current value is
|
||||
initialized through the correct code in a single pass. The persisted map and the
|
||||
max-compat flag are pushed into the controller before discovery (mirroring the existing
|
||||
`SetMaxCompatibilityMode`). The resolver itself is a pure function (no I/O, no
|
||||
persistence) and is fully unit-testable with a fake `probe` delegate.
|
||||
|
||||
## 5. New types (`PowerDisplay.Lib`)
|
||||
|
||||
### 5.1 `VcpFeature` (enum)
|
||||
`Brightness, Contrast, Volume, ColorTemperature, InputSource, PowerState`.
|
||||
Each has a stable persistence/diagnostic key:
|
||||
`brightness, contrast, volume, colorTemperature, inputSource, powerState`.
|
||||
|
||||
### 5.2 `VcpFeatureRegistry` (static)
|
||||
Ordered candidate codes per feature (composed from `NativeConstants` literals, which
|
||||
remain the single source of truth for the values):
|
||||
|
||||
| Feature | Candidates (priority order) |
|
||||
|------------------|-------------------------------------|
|
||||
| Brightness | `0x10` → `0x13` → `0x6B` |
|
||||
| Contrast | `0x12` |
|
||||
| Volume | `0x62` |
|
||||
| ColorTemperature | `0x14` |
|
||||
| InputSource | `0x60` |
|
||||
| PowerState | `0xD6` |
|
||||
|
||||
API: `IReadOnlyList<byte> Candidates(VcpFeature)`, `byte Primary(VcpFeature)` (= first
|
||||
candidate, used as a safe default), `IReadOnlyList<VcpFeature> AllFeatures`, plus
|
||||
`string Key(VcpFeature)` / `bool TryParseKey(string, out VcpFeature)`.
|
||||
|
||||
### 5.3 `VcpFeatureCodeMap`
|
||||
Per-monitor resolved result. Internally `Dictionary<VcpFeature, int>`:
|
||||
- value `0x00`–`0xFF` → resolved code,
|
||||
- value `NotSupportedSentinel` (`= -1`, named constant) → checked, no candidate works,
|
||||
- key absent → that feature not resolved yet.
|
||||
|
||||
The `-1` sentinel is required (not laziness): the monitor-state file serializes with
|
||||
`DefaultIgnoreCondition = WhenWritingNull`, which would silently drop a `null`-means-
|
||||
unsupported encoding and erase the distinction between "checked-unsupported" and
|
||||
"never-resolved". `-1` cannot collide with a real byte code.
|
||||
|
||||
API:
|
||||
- `int GetCode(VcpFeature)` → resolved code if supported, else `VcpFeatureRegistry.Primary`
|
||||
(callers gate on `IsSupported`; primary is a safe fallback so a byte is always returned).
|
||||
- `bool IsSupported(VcpFeature)` → has entry and entry != sentinel.
|
||||
- `bool IsResolved(VcpFeature)` → has any entry (code or sentinel).
|
||||
- `Dictionary<string,int> ToPersisted()` / `static VcpFeatureCodeMap FromPersisted(Dictionary<string,int>?)`
|
||||
(string keys via `VcpFeatureRegistry.Key`; unknown keys ignored for forward-compat).
|
||||
|
||||
### 5.4 `VcpFeatureResolver` (static, pure)
|
||||
```csharp
|
||||
static VcpFeatureCodeMap Resolve(
|
||||
VcpCapabilities caps,
|
||||
bool maxCompatibilityMode,
|
||||
VcpFeatureCodeMap? persisted, // per-feature reuse source
|
||||
Func<byte, bool> probe); // read-only GetVCP, returns true iff usable
|
||||
```
|
||||
Algorithm, per feature in `VcpFeatureRegistry.AllFeatures`:
|
||||
1. **Per-feature reuse.** If `persisted` has an entry (code or sentinel) for this
|
||||
feature → copy it verbatim (no cap-string lookup, no probe). This makes reuse
|
||||
forward-compatible: a persisted map missing a newly-added feature resolves only the
|
||||
missing one.
|
||||
2. Otherwise resolve fresh:
|
||||
1. **Phase 1 (both modes).** First candidate with `caps.SupportsVcpCode(candidate)` → code.
|
||||
2. **Phase 2 (max-compat only, fresh discovery, if Phase 1 found nothing).** Runs only when
|
||||
`persisted == null` — i.e. a first-time discovery or a post-refresh re-resolution (the user
|
||||
Refresh clears persisted maps to null). When a persisted map is supplied (a normal
|
||||
discovery that reuses prior results) probing is skipped entirely, since probing is the
|
||||
expensive path and the persisted decision already covers every feature. First candidate
|
||||
where `probe(candidate)` returns true → code.
|
||||
3. Else → `NotSupportedSentinel`.
|
||||
|
||||
> In practice a persisted map always covers all features (resolution writes every feature), so
|
||||
> the per-feature reuse at the top and the `persisted == null` guard on Phase 2 together yield:
|
||||
> known monitor → reuse, no probe; new monitor or post-refresh → full resolve incl. probe.
|
||||
|
||||
`probe` is supplied by the controller and encodes **usability**, not mere call success:
|
||||
`code => TryGetVcpFeature(handle, code, out cur, out max) && max > 0` — so brightness
|
||||
never resolves to an advertised-but-dead code (aligns with the existing
|
||||
`InitializeBrightness` range guard). Normal mode never invokes `probe`.
|
||||
|
||||
## 6. Controller changes (`DdcCiController`)
|
||||
|
||||
- Add `IReadOnlyDictionary<string, VcpFeatureCodeMap> PersistedVcpCodeMaps { get; set; }`
|
||||
(default empty), pushed before discovery like `MaxCompatibilityMode`.
|
||||
- In `BuildMonitorFromPhysical`, **immediately after `monitor.VcpCapabilitiesInfo = caps;`
|
||||
and before `UpdateMonitorCapabilitiesFromVcp` / the `Initialize*` calls** (ordering is
|
||||
load-bearing — both downstream steps now read the resolved map):
|
||||
```csharp
|
||||
PersistedVcpCodeMaps.TryGetValue(monitor.Id, out var persisted);
|
||||
monitor.ResolvedVcpCodes = VcpFeatureResolver.Resolve(
|
||||
caps, MaxCompatibilityMode, persisted,
|
||||
code => TryGetVcpFeature(physical.HPhysicalMonitor, code, monitor.Id, out var c, out var m) && m > 0);
|
||||
```
|
||||
- `UpdateMonitorCapabilitiesFromVcp`: **brightness support only** now derives from the
|
||||
resolved map — `if (monitor.ResolvedVcpCodes.IsSupported(VcpFeature.Brightness)) monitor.Capabilities |= Brightness;`.
|
||||
Contrast/volume/color-temperature support detection is **unchanged** (still cap-string
|
||||
based); those are single-candidate, so behavior is identical and risk is contained.
|
||||
- `Initialize*` and `Get*/Set*` use `monitor.ResolvedVcpCodes.GetCode(feature)` instead
|
||||
of the `NativeConstants` literal. `InitializeBrightness` reads its max via the resolved
|
||||
brightness code; percent↔raw scaling (`BrightnessVcpMax`) is unchanged. For single-
|
||||
candidate features the resolved code equals the old constant, so reads/writes are
|
||||
byte-for-byte identical to today.
|
||||
|
||||
`Monitor` (model) gains `public VcpFeatureCodeMap ResolvedVcpCodes { get; set; } = new();`.
|
||||
|
||||
## 7. Persistence (`MonitorStateEntry` + `MonitorStateManager`)
|
||||
|
||||
- `MonitorStateEntry` gains
|
||||
`[JsonPropertyName("vcpFeatureCodes")] Dictionary<string,int>? VcpFeatureCodes`
|
||||
(null when never resolved; values include the `-1` sentinel). Register
|
||||
`Dictionary<string,int>` in `MonitorStateSerializationContext`.
|
||||
- `MonitorStateManager` internal `MonitorState` mirrors the field; `LoadStateFromDisk`,
|
||||
`BuildStateJson`, `CloneState` carry it through. New API:
|
||||
- `VcpFeatureCodeMap? GetVcpCodeMap(string monitorId)`
|
||||
- `void UpdateVcpCodeMap(string monitorId, VcpFeatureCodeMap map)` — dirty-flag +
|
||||
debounced save; **skips write if the persisted dict is unchanged** (idempotent).
|
||||
- `void ClearAllVcpCodeMaps()` — nulls only `VcpFeatureCodes` on every entry (brightness/
|
||||
contrast/volume/color values are preserved), marks dirty, schedules save.
|
||||
|
||||
Example `monitor_state.json` entry:
|
||||
```json
|
||||
"\\\\?\\DISPLAY#DELD1A8#5&abc&0&UID1": {
|
||||
"brightness": 75,
|
||||
"vcpFeatureCodes": { "brightness": 107, "contrast": 18, "volume": -1,
|
||||
"colorTemperature": 20, "inputSource": 96, "powerState": 214 },
|
||||
"lastUpdated": "2026-06-30T10:30:45.1"
|
||||
}
|
||||
```
|
||||
(`107`=`0x6B`, `18`=`0x12`, `-1`=not supported, `20`=`0x14`, `96`=`0x60`, `214`=`0xD6`.)
|
||||
|
||||
## 8. App orchestration (`MainViewModel`)
|
||||
|
||||
- **Before discovery** (`InitializeAsync` and `RefreshMonitorsAsync`): build
|
||||
`Dictionary<string, VcpFeatureCodeMap>` from `_stateManager` for all known monitors and
|
||||
push via `_monitorManager.SetPersistedVcpCodeMaps(maps)` (new pass-through to the DDC
|
||||
controller), alongside the existing `SetMaxCompatibilityMode`.
|
||||
- **After discovery** (in `UpdateMonitorList`, where `SaveMonitorsToSettings` already
|
||||
runs): for each discovered monitor call
|
||||
`_stateManager.UpdateVcpCodeMap(monitor.Id, monitor.ResolvedVcpCodes)` (idempotent).
|
||||
- **Refresh clears**: `RefreshMonitorsAsync` calls `_stateManager.ClearAllVcpCodeMaps()`
|
||||
and pushes empty maps **before** re-discovery, forcing full re-resolution. Startup and
|
||||
the watcher path do **not** clear.
|
||||
- The "Refresh" the user means is the existing user-initiated refresh command that calls
|
||||
`RefreshMonitorsAsync` (confirm the exact command/button binding during implementation;
|
||||
the display-change-watcher entry must remain on the reuse path).
|
||||
|
||||
## 9. Diagnostics (`MonitorInfo` + `CreateMonitorInfo`)
|
||||
|
||||
- `MonitorInfo` (Settings.UI.Library) gains
|
||||
`[JsonPropertyName("resolvedVcpCodes")] Dictionary<string,int> ResolvedVcpCodes`
|
||||
and copies it in `UpdateFrom`. Register the dict type in the settings source-gen context.
|
||||
- App `CreateMonitorInfo` populates it from `monitor.ResolvedVcpCodes.ToPersisted()`.
|
||||
- `GetDiagnosticsAsText()` inserts a section between "Detected support" and
|
||||
"Raw capabilities":
|
||||
```
|
||||
Resolved feature -> VCP code
|
||||
--------------------------------------------------
|
||||
Brightness: 0x6B
|
||||
Contrast: 0x12
|
||||
Volume: not supported
|
||||
ColorTemperature: 0x14
|
||||
InputSource: 0x60
|
||||
PowerState: 0xD6
|
||||
```
|
||||
**Hex-only, no friendly names.** `GetDiagnosticsAsText` lives in
|
||||
`Settings.UI.Library`, which must not take a binary dependency on `PowerDisplay.Lib`
|
||||
(where `VcpNames` lives) — that pattern has crashed root apps before. Code names are
|
||||
still available to the reader: the existing `GetVcpCodesAsText()` block below already
|
||||
lists every detected code with its name. Sentinel renders `not supported`; an absent
|
||||
entry renders `not resolved`.
|
||||
|
||||
## 10. Tests (`PowerDisplay.Lib.UnitTests`, MSTest + Moq)
|
||||
|
||||
- `VcpFeatureResolverTests`:
|
||||
- Phase-1 priority: caps with `0x10` → brightness `0x10`; caps with only `0x6B` →
|
||||
brightness `0x6B`; caps with `0x13`+`0x6B` → `0x13` (priority).
|
||||
- Normal mode never probes: assert the `probe` delegate is invoked zero times.
|
||||
- Max-compat probe fallback: caps lists no brightness candidate, `probe(0x6B)` true →
|
||||
`0x6B`; assert probe call order honors priority and stops at first success.
|
||||
- Probe usability: `probe` returns false when `max == 0` → not chosen.
|
||||
- All-miss → sentinel.
|
||||
- Per-feature reuse: a persisted entry is copied verbatim and `probe`/caps are not
|
||||
consulted for that feature; a partially-populated persisted map resolves only the
|
||||
missing features.
|
||||
- `VcpFeatureCodeMapTests`: `ToPersisted`/`FromPersisted` round-trip, sentinel semantics,
|
||||
unknown-key tolerance, `GetCode` fallback to `Primary`.
|
||||
- `MonitorStateManager` round-trip test extended to assert `vcpFeatureCodes` survives
|
||||
save/load and `ClearAllVcpCodeMaps` preserves the value fields.
|
||||
|
||||
## 11. Edge cases & notes
|
||||
|
||||
- **Cap-string totally unreadable + max-compat**: existing `ProbeSupportedVcpFeatures`
|
||||
reconstructs a synthetic caps object from `{0x10,0x12,0x62}` only. Our resolver then
|
||||
runs on that synthetic caps; brightness alternates (`0x13`,`0x6B`) are found via our
|
||||
Phase-2 probe. This causes at most 1–2 redundant `GetVCP` reads on `0x10` in that rare
|
||||
path — accepted, not optimized.
|
||||
- **Discrete features**: `GetCode` returns the standard code; value lists
|
||||
(`SupportedInputSources`, color presets) still come from `caps`. The resolved map only
|
||||
governs which code we read/write.
|
||||
- **Forward/backward compat**: new JSON fields are additive. Old state/settings files
|
||||
lack them → treated as "never resolved" → resolved on next discovery. New files read by
|
||||
an older build → unknown fields ignored.
|
||||
- **Concurrency**: discovery resolves per-monitor concurrently; the pushed persisted map
|
||||
and static registry are read-only during discovery; write-back is single-threaded in the
|
||||
app. No shared mutable state.
|
||||
- **No new external dependencies**; no ABI/native-signature changes; settings/IPC schema
|
||||
only gains additive fields.
|
||||
|
||||
## 12. Out-of-scope follow-ups (not in this change)
|
||||
- Friendly code names in the diagnostics "Resolved" section (would need a shared
|
||||
code-name table in `PowerDisplay.Models`, referenced by both Lib and Settings.UI.Library).
|
||||
- Seeding multi-candidate lists for non-brightness features (extend `VcpFeatureRegistry`).
|
||||
- A per-monitor manual code-override UI.
|
||||
1518
doc/specs/2026-06-30-powerdisplay-vcp-code-resolution-plan.md
Normal file
1518
doc/specs/2026-06-30-powerdisplay-vcp-code-resolution-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Serialization;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
/// <summary>Serialization tests for the MonitorStateEntry VCP-code persistence field.</summary>
|
||||
[TestClass]
|
||||
public class MonitorStateEntrySerializationTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void VcpFeatureCodes_RoundTripsThroughStateContext()
|
||||
{
|
||||
var entry = new MonitorStateEntry
|
||||
{
|
||||
Brightness = 75,
|
||||
VcpFeatureCodes = new Dictionary<string, int> { ["brightness"] = 0x6B, ["volume"] = -1 },
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(entry, MonitorStateSerializationContext.Default.MonitorStateEntry);
|
||||
var roundTripped = JsonSerializer.Deserialize(json, MonitorStateSerializationContext.Default.MonitorStateEntry);
|
||||
|
||||
Assert.IsNotNull(roundTripped);
|
||||
Assert.IsNotNull(roundTripped!.VcpFeatureCodes);
|
||||
Assert.AreEqual(0x6B, roundTripped.VcpFeatureCodes!["brightness"]);
|
||||
Assert.AreEqual(-1, roundTripped.VcpFeatureCodes["volume"]);
|
||||
StringAssert.Contains(json, "vcpFeatureCodes");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NullVcpFeatureCodes_OmittedFromJson()
|
||||
{
|
||||
var entry = new MonitorStateEntry { Brightness = 50 };
|
||||
var json = JsonSerializer.Serialize(entry, MonitorStateSerializationContext.Default.MonitorStateEntry);
|
||||
Assert.IsFalse(json.Contains("vcpFeatureCodes"), "Null map must be omitted (WhenWritingNull).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Utils;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class VcpFeatureCodeMapTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void SetCode_MarksSupported_AndReturnsCode()
|
||||
{
|
||||
var map = new VcpFeatureCodeMap();
|
||||
map.SetCode(VcpFeature.Brightness, 0x6B);
|
||||
|
||||
Assert.IsTrue(map.IsResolved(VcpFeature.Brightness));
|
||||
Assert.IsTrue(map.IsSupported(VcpFeature.Brightness));
|
||||
Assert.AreEqual((byte)0x6B, map.GetCode(VcpFeature.Brightness));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetNotSupported_IsResolvedButNotSupported()
|
||||
{
|
||||
var map = new VcpFeatureCodeMap();
|
||||
map.SetNotSupported(VcpFeature.Volume);
|
||||
|
||||
Assert.IsTrue(map.IsResolved(VcpFeature.Volume));
|
||||
Assert.IsFalse(map.IsSupported(VcpFeature.Volume));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetCode_WhenUnresolved_FallsBackToRegistryPrimary()
|
||||
{
|
||||
var map = new VcpFeatureCodeMap();
|
||||
Assert.AreEqual(VcpFeatureRegistry.Primary(VcpFeature.Brightness), map.GetCode(VcpFeature.Brightness));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetCode_WhenNotSupported_FallsBackToRegistryPrimary()
|
||||
{
|
||||
var map = new VcpFeatureCodeMap();
|
||||
map.SetNotSupported(VcpFeature.Brightness);
|
||||
Assert.AreEqual(VcpFeatureRegistry.Primary(VcpFeature.Brightness), map.GetCode(VcpFeature.Brightness));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToPersisted_UsesStringKeysAndSentinel()
|
||||
{
|
||||
var map = new VcpFeatureCodeMap();
|
||||
map.SetCode(VcpFeature.Brightness, 0x6B);
|
||||
map.SetNotSupported(VcpFeature.Volume);
|
||||
|
||||
var persisted = map.ToPersisted();
|
||||
|
||||
Assert.AreEqual(0x6B, persisted["brightness"]);
|
||||
Assert.AreEqual(-1, persisted["volume"]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FromPersisted_RoundTripsValuesAndSentinel()
|
||||
{
|
||||
var source = new Dictionary<string, int> { ["brightness"] = 0x13, ["contrast"] = -1 };
|
||||
var map = VcpFeatureCodeMap.FromPersisted(source);
|
||||
|
||||
Assert.IsTrue(map.IsSupported(VcpFeature.Brightness));
|
||||
Assert.AreEqual((byte)0x13, map.GetCode(VcpFeature.Brightness));
|
||||
Assert.IsTrue(map.IsResolved(VcpFeature.Contrast));
|
||||
Assert.IsFalse(map.IsSupported(VcpFeature.Contrast));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FromPersisted_Null_ReturnsEmptyMap()
|
||||
{
|
||||
var map = VcpFeatureCodeMap.FromPersisted(null);
|
||||
Assert.IsFalse(map.IsResolved(VcpFeature.Brightness));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FromPersisted_IgnoresUnknownKeys()
|
||||
{
|
||||
var map = VcpFeatureCodeMap.FromPersisted(new Dictionary<string, int> { ["future"] = 5 });
|
||||
Assert.IsFalse(map.IsResolved(VcpFeature.Brightness));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FromPersisted_OutOfRangeValues_AreIgnored()
|
||||
{
|
||||
var map = VcpFeatureCodeMap.FromPersisted(new Dictionary<string, int>
|
||||
{
|
||||
["brightness"] = 300, // > 255
|
||||
["contrast"] = -2, // negative, not the sentinel
|
||||
["volume"] = -1, // valid sentinel
|
||||
["colorTemperature"] = 0x14, // valid code
|
||||
});
|
||||
|
||||
Assert.IsFalse(map.IsResolved(VcpFeature.Brightness));
|
||||
Assert.IsFalse(map.IsResolved(VcpFeature.Contrast));
|
||||
Assert.IsTrue(map.IsResolved(VcpFeature.Volume));
|
||||
Assert.IsFalse(map.IsSupported(VcpFeature.Volume));
|
||||
Assert.AreEqual((byte)0x14, map.GetCode(VcpFeature.ColorTemperature));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Utils;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class VcpFeatureRegistryTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Brightness_HasThreeCandidatesInPriorityOrder()
|
||||
{
|
||||
CollectionAssert.AreEqual(
|
||||
new byte[] { 0x10, 0x13, 0x6B },
|
||||
new List<byte>(VcpFeatureRegistry.Candidates(VcpFeature.Brightness)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SingleCandidateFeatures_MatchTheirStandardCode()
|
||||
{
|
||||
Assert.AreEqual((byte)0x12, VcpFeatureRegistry.Primary(VcpFeature.Contrast));
|
||||
Assert.AreEqual((byte)0x62, VcpFeatureRegistry.Primary(VcpFeature.Volume));
|
||||
Assert.AreEqual((byte)0x14, VcpFeatureRegistry.Primary(VcpFeature.ColorTemperature));
|
||||
Assert.AreEqual((byte)0x60, VcpFeatureRegistry.Primary(VcpFeature.InputSource));
|
||||
Assert.AreEqual((byte)0xD6, VcpFeatureRegistry.Primary(VcpFeature.PowerState));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Primary_IsFirstCandidate()
|
||||
{
|
||||
Assert.AreEqual((byte)0x10, VcpFeatureRegistry.Primary(VcpFeature.Brightness));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AllFeatures_ContainsEverySixFeatures()
|
||||
{
|
||||
Assert.AreEqual(6, VcpFeatureRegistry.AllFeatures.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Key_RoundTripsThroughTryParseKey()
|
||||
{
|
||||
foreach (var feature in VcpFeatureRegistry.AllFeatures)
|
||||
{
|
||||
Assert.IsTrue(VcpFeatureRegistry.TryParseKey(VcpFeatureRegistry.Key(feature), out var parsed));
|
||||
Assert.AreEqual(feature, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParseKey_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
Assert.IsFalse(VcpFeatureRegistry.TryParseKey("nonsense", out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Utils;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class VcpFeatureResolverTests
|
||||
{
|
||||
private static VcpCapabilities CapsWith(params byte[] codes)
|
||||
{
|
||||
var caps = new VcpCapabilities();
|
||||
foreach (var code in codes)
|
||||
{
|
||||
caps.SupportedVcpCodes[code] = new VcpCodeInfo(code, "test");
|
||||
}
|
||||
|
||||
return caps;
|
||||
}
|
||||
|
||||
private static Func<byte, bool> NeverProbe(List<byte> log) => code =>
|
||||
{
|
||||
log.Add(code);
|
||||
return false;
|
||||
};
|
||||
|
||||
[TestMethod]
|
||||
public void Phase1_PicksFirstCandidatePresentInCaps()
|
||||
{
|
||||
var caps = CapsWith(0x10, 0x12, 0x62);
|
||||
var probeLog = new List<byte>();
|
||||
|
||||
var map = VcpFeatureResolver.Resolve(caps, maxCompatibilityMode: false, persisted: null, probe: NeverProbe(probeLog));
|
||||
|
||||
Assert.AreEqual((byte)0x10, map.GetCode(VcpFeature.Brightness));
|
||||
Assert.AreEqual(0, probeLog.Count, "Normal mode must never probe.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Phase1_FallsToLowerPriorityCandidate()
|
||||
{
|
||||
// 0x10 absent, 0x6B present -> brightness resolves to 0x6B.
|
||||
var caps = CapsWith(0x6B);
|
||||
|
||||
var map = VcpFeatureResolver.Resolve(caps, maxCompatibilityMode: false, persisted: null, probe: _ => false);
|
||||
|
||||
Assert.AreEqual((byte)0x6B, map.GetCode(VcpFeature.Brightness));
|
||||
Assert.IsTrue(map.IsSupported(VcpFeature.Brightness));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Phase1_HonorsPriorityWhenMultiplePresent()
|
||||
{
|
||||
// VcpFeatureRegistry.Candidates(VcpFeature.Brightness) == [0x10, 0x13, 0x6B] (priority order).
|
||||
var caps = CapsWith(0x13, 0x6B); // both alternates present, no 0x10
|
||||
var map = VcpFeatureResolver.Resolve(caps, maxCompatibilityMode: false, persisted: null, probe: _ => false);
|
||||
Assert.AreEqual((byte)0x13, map.GetCode(VcpFeature.Brightness));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NormalMode_NoCandidate_ResolvesNotSupported_WithoutProbing()
|
||||
{
|
||||
var caps = CapsWith(0x12); // contrast only; brightness candidates absent
|
||||
var probeLog = new List<byte>();
|
||||
|
||||
var map = VcpFeatureResolver.Resolve(caps, maxCompatibilityMode: false, persisted: null, probe: NeverProbe(probeLog));
|
||||
|
||||
Assert.IsFalse(map.IsSupported(VcpFeature.Brightness));
|
||||
Assert.IsTrue(map.IsResolved(VcpFeature.Brightness));
|
||||
Assert.AreEqual(0, probeLog.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MaxCompat_ProbesInPriorityOrder_AndStopsAtFirstSuccess()
|
||||
{
|
||||
// VcpFeatureRegistry.Candidates(VcpFeature.Brightness) == [0x10, 0x13, 0x6B] (priority order).
|
||||
var caps = CapsWith(); // empty: no candidate for any feature
|
||||
var probeLog = new List<byte>();
|
||||
Func<byte, bool> probe = code =>
|
||||
{
|
||||
probeLog.Add(code);
|
||||
return code == 0x6B; // only the third brightness candidate responds
|
||||
};
|
||||
|
||||
var map = VcpFeatureResolver.Resolve(caps, maxCompatibilityMode: true, persisted: null, probe: probe);
|
||||
|
||||
Assert.AreEqual((byte)0x6B, map.GetCode(VcpFeature.Brightness));
|
||||
|
||||
// Assert the first 3 brightness probes happened in order 0x10, 0x13, 0x6B (stops at 0x6B for brightness).
|
||||
CollectionAssert.AreEqual(new byte[] { 0x10, 0x13, 0x6B }, probeLog.GetRange(0, 3));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MaxCompat_NoCandidateResponds_ResolvesNotSupported()
|
||||
{
|
||||
var caps = CapsWith();
|
||||
var map = VcpFeatureResolver.Resolve(caps, maxCompatibilityMode: true, persisted: null, probe: _ => false);
|
||||
Assert.IsFalse(map.IsSupported(VcpFeature.Brightness));
|
||||
Assert.IsTrue(map.IsResolved(VcpFeature.Brightness));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Persisted_IsReusedVerbatim_WithoutCapsOrProbe()
|
||||
{
|
||||
var persisted = new VcpFeatureCodeMap();
|
||||
persisted.SetCode(VcpFeature.Brightness, 0x6B);
|
||||
persisted.SetNotSupported(VcpFeature.Volume);
|
||||
|
||||
var probeLog = new List<byte>();
|
||||
|
||||
// caps says brightness is on 0x10, but persisted (0x6B) must win.
|
||||
var map = VcpFeatureResolver.Resolve(CapsWith(0x10, 0x62), maxCompatibilityMode: true, persisted: persisted, probe: NeverProbe(probeLog));
|
||||
|
||||
Assert.AreEqual((byte)0x6B, map.GetCode(VcpFeature.Brightness));
|
||||
Assert.IsFalse(map.IsSupported(VcpFeature.Volume));
|
||||
Assert.IsTrue(map.IsResolved(VcpFeature.Volume));
|
||||
Assert.AreEqual(0, probeLog.Count, "Reused features must not be probed.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Persisted_PartialMap_ResolvesOnlyMissingFeatures()
|
||||
{
|
||||
var persisted = new VcpFeatureCodeMap();
|
||||
persisted.SetCode(VcpFeature.Brightness, 0x6B); // only brightness persisted
|
||||
|
||||
// contrast resolved fresh from caps; brightness reused.
|
||||
var map = VcpFeatureResolver.Resolve(CapsWith(0x12), maxCompatibilityMode: false, persisted: persisted, probe: _ => false);
|
||||
|
||||
Assert.AreEqual((byte)0x6B, map.GetCode(VcpFeature.Brightness));
|
||||
Assert.IsTrue(map.IsSupported(VcpFeature.Contrast));
|
||||
Assert.AreEqual((byte)0x12, map.GetCode(VcpFeature.Contrast));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,14 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
/// </summary>
|
||||
public bool MaxCompatibilityMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-monitor persisted resolved-code maps (keyed by <c>Monitor.Id</c>),
|
||||
/// pushed by <see cref="MonitorManager"/> before each discovery. A monitor present here
|
||||
/// reuses its persisted decision instead of re-resolving/probing. Default: empty.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, VcpFeatureCodeMap> PersistedVcpCodeMaps { get; set; }
|
||||
= new Dictionary<string, VcpFeatureCodeMap>(MonitorIdComparer.Instance);
|
||||
|
||||
public DdcCiController()
|
||||
{
|
||||
_discoveryHelper = new MonitorDiscoveryHelper();
|
||||
@@ -55,7 +63,7 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
public async Task<VcpFeatureValue> GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
return await GetVcpFeatureAsync(monitor, VcpCodeBrightness, cancellationToken);
|
||||
return await GetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.Brightness), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -63,14 +71,14 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
var raw = VcpFeatureValue.FromPercentage(brightness, monitor.BrightnessVcpMax);
|
||||
return SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeBrightness, raw, cancellationToken);
|
||||
return SetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.Brightness), raw, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VcpFeatureValue> GetContrastAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
return await GetVcpFeatureAsync(monitor, VcpCodeContrast, cancellationToken);
|
||||
return await GetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.Contrast), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -78,14 +86,14 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
var raw = VcpFeatureValue.FromPercentage(contrast, monitor.ContrastVcpMax);
|
||||
return SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeContrast, raw, cancellationToken);
|
||||
return SetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.Contrast), raw, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VcpFeatureValue> GetVolumeAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
return await GetVcpFeatureAsync(monitor, VcpCodeVolume, cancellationToken);
|
||||
return await GetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.Volume), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -93,57 +101,57 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
var raw = VcpFeatureValue.FromPercentage(volume, monitor.VolumeVcpMax);
|
||||
return SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeVolume, raw, cancellationToken);
|
||||
return SetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.Volume), raw, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get monitor color temperature using VCP code 0x14 (Select Color Preset)
|
||||
/// Returns the raw VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature
|
||||
/// Get monitor color temperature using the resolved color-temperature VCP code.
|
||||
/// Returns the raw VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature.
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
return await GetVcpFeatureAsync(monitor, VcpCodeSelectColorPreset, cancellationToken);
|
||||
return await GetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.ColorTemperature), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set monitor color temperature using VCP code 0x14 (Select Color Preset)
|
||||
/// Set monitor color temperature using the resolved color-temperature VCP code.
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default)
|
||||
=> SetVcpFeatureAsync(monitor, VcpCodeSelectColorPreset, colorTemperature, cancellationToken);
|
||||
=> SetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.ColorTemperature), colorTemperature, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get current input source using VCP code 0x60
|
||||
/// Returns the raw VCP value (e.g., 0x11 for HDMI-1)
|
||||
/// Get current input source using the resolved input-source VCP code.
|
||||
/// Returns the raw VCP value (e.g., 0x11 for HDMI-1).
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
return await GetVcpFeatureAsync(monitor, VcpCodeInputSource, cancellationToken);
|
||||
return await GetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.InputSource), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set input source using VCP code 0x60
|
||||
/// Set input source using the resolved input-source VCP code.
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default)
|
||||
=> SetVcpFeatureAsync(monitor, VcpCodeInputSource, inputSource, cancellationToken);
|
||||
=> SetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.InputSource), inputSource, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Set power state using VCP code 0xD6 (Power Mode).
|
||||
/// Set power state for a monitor using the resolved power-state VCP code.
|
||||
/// Values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard).
|
||||
/// Note: Setting any value other than 0x01 (On) will turn off the display.
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> SetPowerStateAsync(Monitor monitor, int powerState, CancellationToken cancellationToken = default)
|
||||
=> SetVcpFeatureAsync(monitor, VcpCodePowerMode, powerState, cancellationToken);
|
||||
=> SetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.PowerState), powerState, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get current power state using VCP code 0xD6 (Power Mode).
|
||||
/// Get current power state for a monitor using the resolved power-state VCP code.
|
||||
/// Returns the raw VCP value (0x01=On, 0x02=Standby, etc.)
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> GetPowerStateAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
return await GetVcpFeatureAsync(monitor, VcpCodePowerMode, cancellationToken);
|
||||
return await GetVcpFeatureAsync(monitor, monitor.ResolvedVcpCodes.GetCode(VcpFeature.PowerState), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -305,6 +313,16 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
}
|
||||
|
||||
monitor.VcpCapabilitiesInfo = caps;
|
||||
|
||||
// Resolve each feature to a concrete VCP code BEFORE capability flags and
|
||||
// value initialization, both of which now read monitor.ResolvedVcpCodes.
|
||||
PersistedVcpCodeMaps.TryGetValue(monitor.Id, out var persistedCodeMap);
|
||||
monitor.ResolvedVcpCodes = VcpFeatureResolver.Resolve(
|
||||
caps,
|
||||
MaxCompatibilityMode,
|
||||
persistedCodeMap,
|
||||
code => TryGetVcpFeature(physical.HPhysicalMonitor, code, monitor.Id, out _, out var max) && max > 0);
|
||||
|
||||
UpdateMonitorCapabilitiesFromVcp(monitor, caps);
|
||||
|
||||
// Initialize current values for every VCP feature the device reports
|
||||
@@ -437,22 +455,24 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize input source value for a monitor using VCP 0x60.
|
||||
/// Initialize input source value for a monitor using the resolved input-source VCP code.
|
||||
/// </summary>
|
||||
private static void InitializeInputSource(Monitor monitor, IntPtr handle)
|
||||
{
|
||||
if (TryGetVcpFeature(handle, VcpCodeInputSource, monitor.Id, out uint current, out uint _))
|
||||
var code = monitor.ResolvedVcpCodes.GetCode(VcpFeature.InputSource);
|
||||
if (TryGetVcpFeature(handle, code, monitor.Id, out uint current, out uint _))
|
||||
{
|
||||
monitor.CurrentInputSource = (int)current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize color temperature value for a monitor using VCP 0x14.
|
||||
/// Initialize color temperature value for a monitor using the resolved color-temperature VCP code.
|
||||
/// </summary>
|
||||
private static void InitializeColorTemperature(Monitor monitor, IntPtr handle)
|
||||
{
|
||||
if (TryGetVcpFeature(handle, VcpCodeSelectColorPreset, monitor.Id, out uint current, out uint _))
|
||||
var code = monitor.ResolvedVcpCodes.GetCode(VcpFeature.ColorTemperature);
|
||||
if (TryGetVcpFeature(handle, code, monitor.Id, out uint current, out uint _))
|
||||
{
|
||||
monitor.CurrentColorTemperature = (int)current;
|
||||
}
|
||||
@@ -463,19 +483,21 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
/// </summary>
|
||||
private static void InitializePowerState(Monitor monitor, IntPtr handle)
|
||||
{
|
||||
if (TryGetVcpFeature(handle, VcpCodePowerMode, monitor.Id, out uint current, out uint _))
|
||||
var code = monitor.ResolvedVcpCodes.GetCode(VcpFeature.PowerState);
|
||||
if (TryGetVcpFeature(handle, code, monitor.Id, out uint current, out uint _))
|
||||
{
|
||||
monitor.CurrentPowerState = (int)current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize brightness value for a monitor using VCP 0x10.
|
||||
/// Initialize brightness value for a monitor using the resolved brightness VCP code.
|
||||
/// Persists the device-reported raw maximum so subsequent writes can scale percent → raw.
|
||||
/// </summary>
|
||||
private static void InitializeBrightness(Monitor monitor, IntPtr handle)
|
||||
{
|
||||
if (TryGetVcpFeature(handle, VcpCodeBrightness, monitor.Id, out uint current, out uint max))
|
||||
var code = monitor.ResolvedVcpCodes.GetCode(VcpFeature.Brightness);
|
||||
if (TryGetVcpFeature(handle, code, monitor.Id, out uint current, out uint max))
|
||||
{
|
||||
var brightnessInfo = new VcpFeatureValue((int)current, 0, (int)max);
|
||||
if (!brightnessInfo.IsValid)
|
||||
@@ -491,12 +513,13 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize contrast value for a monitor using VCP 0x12.
|
||||
/// Initialize contrast value for a monitor using the resolved contrast VCP code.
|
||||
/// Persists the device-reported raw maximum so subsequent writes can scale percent → raw.
|
||||
/// </summary>
|
||||
private static void InitializeContrast(Monitor monitor, IntPtr handle)
|
||||
{
|
||||
if (TryGetVcpFeature(handle, VcpCodeContrast, monitor.Id, out uint current, out uint max))
|
||||
var code = monitor.ResolvedVcpCodes.GetCode(VcpFeature.Contrast);
|
||||
if (TryGetVcpFeature(handle, code, monitor.Id, out uint current, out uint max))
|
||||
{
|
||||
monitor.ContrastVcpMax = (int)max;
|
||||
var contrastInfo = new VcpFeatureValue((int)current, 0, (int)max);
|
||||
@@ -505,12 +528,13 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize volume value for a monitor using VCP 0x62.
|
||||
/// Initialize volume value for a monitor using the resolved volume VCP code.
|
||||
/// Persists the device-reported raw maximum so subsequent writes can scale percent → raw.
|
||||
/// </summary>
|
||||
private static void InitializeVolume(Monitor monitor, IntPtr handle)
|
||||
{
|
||||
if (TryGetVcpFeature(handle, VcpCodeVolume, monitor.Id, out uint current, out uint max))
|
||||
var code = monitor.ResolvedVcpCodes.GetCode(VcpFeature.Volume);
|
||||
if (TryGetVcpFeature(handle, code, monitor.Id, out uint current, out uint max))
|
||||
{
|
||||
monitor.VolumeVcpMax = (int)max;
|
||||
var volumeInfo = new VcpFeatureValue((int)current, 0, (int)max);
|
||||
@@ -545,8 +569,9 @@ namespace PowerDisplay.Common.Drivers.DDC
|
||||
/// </summary>
|
||||
private static void UpdateMonitorCapabilitiesFromVcp(Monitor monitor, VcpCapabilities vcpCaps)
|
||||
{
|
||||
// Check for Brightness support (VCP 0x10)
|
||||
if (vcpCaps.SupportsVcpCode(VcpCodeBrightness))
|
||||
// Brightness support derives from the resolved map so a monitor that exposes
|
||||
// brightness only via an alternate code (0x13/0x6B) is still recognized.
|
||||
if (monitor.ResolvedVcpCodes.IsSupported(VcpFeature.Brightness))
|
||||
{
|
||||
monitor.Capabilities |= MonitorCapabilities.Brightness;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,22 @@ namespace PowerDisplay.Common.Drivers
|
||||
/// <summary>
|
||||
/// VCP code: Brightness (0x10)
|
||||
/// Standard VESA MCCS brightness control.
|
||||
/// This is the ONLY brightness code used by PowerDisplay.
|
||||
/// Primary brightness candidate; see also VcpCodeBacklightControl (0x13) and VcpCodeBacklightLevelWhite (0x6B).
|
||||
/// </summary>
|
||||
public const byte VcpCodeBrightness = 0x10;
|
||||
|
||||
/// <summary>
|
||||
/// VCP code: Backlight Control (0x13).
|
||||
/// Alternate brightness control used by some panels that do not implement 0x10.
|
||||
/// </summary>
|
||||
public const byte VcpCodeBacklightControl = 0x13;
|
||||
|
||||
/// <summary>
|
||||
/// VCP code: Backlight Level: White (0x6B).
|
||||
/// Alternate brightness control used by some panels that expose the backlight directly.
|
||||
/// </summary>
|
||||
public const byte VcpCodeBacklightLevelWhite = 0x6B;
|
||||
|
||||
/// <summary>
|
||||
/// VCP code: Contrast (0x12)
|
||||
/// Standard VESA MCCS contrast control.
|
||||
|
||||
@@ -264,6 +264,13 @@ namespace PowerDisplay.Common.Models
|
||||
/// </summary>
|
||||
public VcpCapabilities? VcpCapabilitiesInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-feature resolved VCP code map for this monitor, produced by
|
||||
/// <see cref="PowerDisplay.Common.Utils.VcpFeatureResolver"/> during discovery and
|
||||
/// persisted via <c>MonitorStateManager</c>.
|
||||
/// </summary>
|
||||
public VcpFeatureCodeMap ResolvedVcpCodes { get; set; } = new();
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
@@ -43,6 +44,14 @@ namespace PowerDisplay.Common.Models
|
||||
[JsonPropertyName("capabilitiesRaw")]
|
||||
public string? CapabilitiesRaw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the resolved feature-to-VCP-code map (JSON key = feature name,
|
||||
/// value = VCP code, or <c>-1</c> = checked-but-unsupported). <c>null</c> when the
|
||||
/// monitor's features have never been resolved.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vcpFeatureCodes")]
|
||||
public Dictionary<string, int>? VcpFeatureCodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when this entry was last updated.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Logical monitor features that may map to one of several candidate VCP codes.
|
||||
/// </summary>
|
||||
public enum VcpFeature
|
||||
{
|
||||
Brightness,
|
||||
Contrast,
|
||||
Volume,
|
||||
ColorTemperature,
|
||||
InputSource,
|
||||
PowerState,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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 PowerDisplay.Common.Utils;
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-monitor resolved VCP code for each <see cref="VcpFeature"/>:
|
||||
/// a real code (0x00-0xFF), the <see cref="NotSupportedSentinel"/> (checked, no
|
||||
/// candidate worked), or absent (not yet resolved). Persisted as
|
||||
/// <c>Dictionary<string,int></c> keyed by <see cref="VcpFeatureRegistry.Key"/>.
|
||||
/// </summary>
|
||||
public sealed class VcpFeatureCodeMap
|
||||
{
|
||||
/// <summary>
|
||||
/// Value stored for a feature that was checked but has no usable candidate code.
|
||||
/// A non-null sentinel is required because the monitor-state file serializes with
|
||||
/// <c>WhenWritingNull</c>, which would drop a null and lose the "checked" fact.
|
||||
/// </summary>
|
||||
public const int NotSupportedSentinel = -1;
|
||||
|
||||
private readonly Dictionary<VcpFeature, int> _codes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if <paramref name="feature"/> has been resolved
|
||||
/// (supported or explicitly marked not supported); <see langword="false"/> if still
|
||||
/// pending resolution.
|
||||
/// </summary>
|
||||
/// <param name="feature">The feature to check.</param>
|
||||
/// <returns><see langword="true"/> when a resolution result has been stored.</returns>
|
||||
public bool IsResolved(VcpFeature feature) => _codes.ContainsKey(feature);
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if <paramref name="feature"/> was resolved to a
|
||||
/// usable VCP code (i.e., not <see cref="NotSupportedSentinel"/>).
|
||||
/// </summary>
|
||||
/// <param name="feature">The feature to check.</param>
|
||||
/// <returns><see langword="true"/> when a real code is stored for the feature.</returns>
|
||||
public bool IsSupported(VcpFeature feature) =>
|
||||
_codes.TryGetValue(feature, out var code) && code != NotSupportedSentinel;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the resolved code when supported; otherwise the registry's primary
|
||||
/// candidate as a safe default (callers gate writes on <see cref="IsSupported"/>).
|
||||
/// </summary>
|
||||
/// <param name="feature">The feature whose VCP code is requested.</param>
|
||||
/// <returns>
|
||||
/// The stored VCP code when <paramref name="feature"/> is supported; otherwise the
|
||||
/// first candidate from <see cref="VcpFeatureRegistry.Primary"/>.
|
||||
/// </returns>
|
||||
public byte GetCode(VcpFeature feature) =>
|
||||
_codes.TryGetValue(feature, out var code) && code != NotSupportedSentinel
|
||||
? (byte)code
|
||||
: VcpFeatureRegistry.Primary(feature);
|
||||
|
||||
/// <summary>
|
||||
/// Records a resolved VCP code for <paramref name="feature"/>.
|
||||
/// </summary>
|
||||
/// <param name="feature">The feature being resolved.</param>
|
||||
/// <param name="code">The VCP code that responded on this monitor.</param>
|
||||
public void SetCode(VcpFeature feature, byte code) => _codes[feature] = code;
|
||||
|
||||
/// <summary>
|
||||
/// Marks <paramref name="feature"/> as resolved but not supported on this monitor
|
||||
/// (no candidate code elicited a valid response).
|
||||
/// </summary>
|
||||
/// <param name="feature">The feature to mark as not supported.</param>
|
||||
public void SetNotSupported(VcpFeature feature) => _codes[feature] = NotSupportedSentinel;
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the map to a <c>Dictionary<string,int></c> suitable for JSON
|
||||
/// persistence. Keys are the stable strings from <see cref="VcpFeatureRegistry.Key"/>;
|
||||
/// values are byte codes or <see cref="NotSupportedSentinel"/>.
|
||||
/// </summary>
|
||||
/// <returns>Serialisable dictionary of resolved codes.</returns>
|
||||
public Dictionary<string, int> ToPersisted()
|
||||
{
|
||||
var result = new Dictionary<string, int>(_codes.Count);
|
||||
foreach (var kvp in _codes)
|
||||
{
|
||||
result[VcpFeatureRegistry.Key(kvp.Key)] = kvp.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialises a <see cref="VcpFeatureCodeMap"/> from a previously persisted
|
||||
/// dictionary. Unknown keys are silently ignored; <see langword="null"/> input
|
||||
/// returns an empty (unresolved) map. Values outside the valid byte range [0, 255]
|
||||
/// that are not the <see cref="NotSupportedSentinel"/> are also ignored (treated as
|
||||
/// unresolved) to guard against corrupt or hand-edited state files.
|
||||
/// </summary>
|
||||
/// <param name="persisted">
|
||||
/// Dictionary produced by <see cref="ToPersisted"/>, or <see langword="null"/>.
|
||||
/// </param>
|
||||
/// <returns>A new <see cref="VcpFeatureCodeMap"/> populated from <paramref name="persisted"/>.</returns>
|
||||
public static VcpFeatureCodeMap FromPersisted(Dictionary<string, int>? persisted)
|
||||
{
|
||||
var map = new VcpFeatureCodeMap();
|
||||
if (persisted == null)
|
||||
{
|
||||
return map;
|
||||
}
|
||||
|
||||
foreach (var kvp in persisted)
|
||||
{
|
||||
if (VcpFeatureRegistry.TryParseKey(kvp.Key, out var feature) &&
|
||||
(kvp.Value == NotSupportedSentinel || (kvp.Value >= 0 && kvp.Value <= 255)))
|
||||
{
|
||||
map._codes[feature] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace PowerDisplay.Common.Serialization
|
||||
[JsonSerializable(typeof(MonitorStateFile))]
|
||||
[JsonSerializable(typeof(MonitorStateEntry))]
|
||||
[JsonSerializable(typeof(Dictionary<string, MonitorStateEntry>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, int>))]
|
||||
public partial class MonitorStateSerializationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ namespace PowerDisplay.Common.Services
|
||||
public int? Volume { get; set; }
|
||||
|
||||
public string? CapabilitiesRaw { get; set; }
|
||||
|
||||
public Dictionary<string, int>? VcpFeatureCodes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -146,6 +148,97 @@ namespace PowerDisplay.Common.Services
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the persisted resolved feature-to-VCP-code map for a monitor, or <c>null</c>
|
||||
/// if it has never been resolved.
|
||||
/// </summary>
|
||||
public VcpFeatureCodeMap? GetVcpCodeMap(string monitorId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(monitorId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_states.TryGetValue(monitorId, out var state) && state.VcpFeatureCodes != null)
|
||||
{
|
||||
return VcpFeatureCodeMap.FromPersisted(state.VcpFeatureCodes);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the resolved feature-to-VCP-code map for a monitor. Idempotent: skips the
|
||||
/// write when the serialized map is unchanged, so re-running discovery does not churn disk.
|
||||
/// </summary>
|
||||
public void UpdateVcpCodeMap(string monitorId, VcpFeatureCodeMap map)
|
||||
{
|
||||
if (string.IsNullOrEmpty(monitorId) || map == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var persisted = map.ToPersisted();
|
||||
var state = _states.GetOrAdd(monitorId, _ => new MonitorState());
|
||||
if (DictionariesEqual(state.VcpFeatureCodes, persisted))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
state.VcpFeatureCodes = persisted;
|
||||
_isDirty = true;
|
||||
_saveDebouncer.Debounce(SaveStateToDiskAsync);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to update VCP code map for monitorId '{monitorId}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears every monitor's resolved feature-to-VCP-code map (leaving brightness/contrast/
|
||||
/// volume/color values intact), forcing a fresh resolution on the next discovery. Called
|
||||
/// by the user-initiated Refresh path.
|
||||
/// </summary>
|
||||
public void ClearAllVcpCodeMaps()
|
||||
{
|
||||
bool changed = false;
|
||||
foreach (var state in _states.Values)
|
||||
{
|
||||
if (state.VcpFeatureCodes != null)
|
||||
{
|
||||
state.VcpFeatureCodes = null;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
_isDirty = true;
|
||||
_saveDebouncer.Debounce(SaveStateToDiskAsync);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DictionariesEqual(Dictionary<string, int>? a, Dictionary<string, int> b)
|
||||
{
|
||||
if (a == null || a.Count != b.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var kvp in b)
|
||||
{
|
||||
if (!a.TryGetValue(kvp.Key, out var v) || v != kvp.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-shot upgrade migration: rewrite legacy <c>"{Source}_{EdidId}_{N}"</c> keys
|
||||
/// (pre-PR #47712) onto the matching DevicePath-based monitor Ids by joining on
|
||||
@@ -221,6 +314,7 @@ namespace PowerDisplay.Common.Services
|
||||
Contrast = s.Contrast,
|
||||
Volume = s.Volume,
|
||||
CapabilitiesRaw = s.CapabilitiesRaw,
|
||||
VcpFeatureCodes = s.VcpFeatureCodes,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -252,6 +346,7 @@ namespace PowerDisplay.Common.Services
|
||||
Contrast = entry.Contrast,
|
||||
Volume = entry.Volume,
|
||||
CapabilitiesRaw = entry.CapabilitiesRaw,
|
||||
VcpFeatureCodes = entry.VcpFeatureCodes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -333,6 +428,7 @@ namespace PowerDisplay.Common.Services
|
||||
Contrast = state.Contrast,
|
||||
Volume = state.Volume,
|
||||
CapabilitiesRaw = state.CapabilitiesRaw,
|
||||
VcpFeatureCodes = state.VcpFeatureCodes,
|
||||
LastUpdated = now,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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 PowerDisplay.Common.Models;
|
||||
using static PowerDisplay.Common.Drivers.NativeConstants;
|
||||
|
||||
namespace PowerDisplay.Common.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps each <see cref="VcpFeature"/> to its ordered list of candidate VCP codes
|
||||
/// (highest priority first) and a stable persistence/diagnostic key. Candidate
|
||||
/// values come from <see cref="PowerDisplay.Common.Drivers.NativeConstants"/>.
|
||||
/// Only brightness has multiple candidates today; the rest are single-candidate.
|
||||
/// </summary>
|
||||
public static class VcpFeatureRegistry
|
||||
{
|
||||
private static readonly VcpFeature[] AllFeaturesArray =
|
||||
{
|
||||
VcpFeature.Brightness,
|
||||
VcpFeature.Contrast,
|
||||
VcpFeature.Volume,
|
||||
VcpFeature.ColorTemperature,
|
||||
VcpFeature.InputSource,
|
||||
VcpFeature.PowerState,
|
||||
};
|
||||
|
||||
private static readonly Dictionary<VcpFeature, byte[]> CandidatesByFeature = new()
|
||||
{
|
||||
[VcpFeature.Brightness] = new[] { VcpCodeBrightness, VcpCodeBacklightControl, VcpCodeBacklightLevelWhite },
|
||||
[VcpFeature.Contrast] = new[] { VcpCodeContrast },
|
||||
[VcpFeature.Volume] = new[] { VcpCodeVolume },
|
||||
[VcpFeature.ColorTemperature] = new[] { VcpCodeSelectColorPreset },
|
||||
[VcpFeature.InputSource] = new[] { VcpCodeInputSource },
|
||||
[VcpFeature.PowerState] = new[] { VcpCodePowerMode },
|
||||
};
|
||||
|
||||
private static readonly Dictionary<VcpFeature, string> KeysByFeature = new()
|
||||
{
|
||||
[VcpFeature.Brightness] = "brightness",
|
||||
[VcpFeature.Contrast] = "contrast",
|
||||
[VcpFeature.Volume] = "volume",
|
||||
[VcpFeature.ColorTemperature] = "colorTemperature",
|
||||
[VcpFeature.InputSource] = "inputSource",
|
||||
[VcpFeature.PowerState] = "powerState",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// All supported <see cref="VcpFeature"/> values in display order.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<VcpFeature> AllFeatures => AllFeaturesArray;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ordered list of candidate VCP codes for <paramref name="feature"/>,
|
||||
/// highest priority first.
|
||||
/// </summary>
|
||||
/// <param name="feature">The feature whose candidates are requested.</param>
|
||||
/// <returns>Ordered candidate byte values (highest priority first).</returns>
|
||||
public static IReadOnlyList<byte> Candidates(VcpFeature feature) => CandidatesByFeature[feature];
|
||||
|
||||
/// <summary>
|
||||
/// Returns the highest-priority VCP code for <paramref name="feature"/>.
|
||||
/// </summary>
|
||||
/// <param name="feature">The feature whose primary code is requested.</param>
|
||||
/// <returns>The first (highest-priority) candidate VCP code.</returns>
|
||||
public static byte Primary(VcpFeature feature) => CandidatesByFeature[feature][0];
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stable persistence/diagnostic key string for <paramref name="feature"/>
|
||||
/// (e.g., <c>"brightness"</c>).
|
||||
/// </summary>
|
||||
/// <param name="feature">The feature whose key is requested.</param>
|
||||
/// <returns>Lowercase camelCase key string.</returns>
|
||||
public static string Key(VcpFeature feature) => KeysByFeature[feature];
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to look up the <see cref="VcpFeature"/> corresponding to a persistence key.
|
||||
/// </summary>
|
||||
/// <param name="key">Key string previously returned by <see cref="Key"/>.</param>
|
||||
/// <param name="feature">
|
||||
/// When this method returns <see langword="true"/>, contains the matching feature;
|
||||
/// otherwise the default value.
|
||||
/// </param>
|
||||
/// <returns><see langword="true"/> if the key was recognised; otherwise <see langword="false"/>.</returns>
|
||||
public static bool TryParseKey(string key, out VcpFeature feature)
|
||||
{
|
||||
foreach (var kvp in KeysByFeature)
|
||||
{
|
||||
if (string.Equals(kvp.Value, key, StringComparison.Ordinal))
|
||||
{
|
||||
feature = kvp.Key;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
feature = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using PowerDisplay.Common.Models;
|
||||
|
||||
namespace PowerDisplay.Common.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves each <see cref="VcpFeature"/> to a concrete VCP code for one monitor:
|
||||
/// cap-string-first, probe-as-fallback. Pure and side-effect free — the only I/O is
|
||||
/// the caller-supplied <paramref name="probe"/> delegate, which must be a read-only
|
||||
/// VCP GET that returns true only when the code yields a usable value.
|
||||
/// </summary>
|
||||
public static class VcpFeatureResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves every <see cref="VcpFeature"/> to a VCP code (or not-supported sentinel)
|
||||
/// for a single monitor, using cap-string-first then probe-as-fallback strategy.
|
||||
/// <para>
|
||||
/// Algorithm per feature:
|
||||
/// <list type="number">
|
||||
/// <item>If <paramref name="persisted"/> already has a resolved decision (code or
|
||||
/// sentinel), reuse it verbatim without touching <paramref name="caps"/> or
|
||||
/// <paramref name="probe"/>.</item>
|
||||
/// <item>Phase 1 (both modes): walk the priority-ordered candidates from
|
||||
/// <see cref="VcpFeatureRegistry.Candidates"/>; pick the first code the cap
|
||||
/// string reports as supported.</item>
|
||||
/// <item>Phase 2 (<paramref name="maxCompatibilityMode"/> only, fresh discovery
|
||||
/// when <paramref name="persisted"/> is <see langword="null"/>, and Phase 1 found
|
||||
/// nothing): call <paramref name="probe"/> on each candidate in priority order;
|
||||
/// stop at the first that returns <see langword="true"/>. Skipped when
|
||||
/// <paramref name="persisted"/> is non-<see langword="null"/> (cap-string refresh
|
||||
/// mode — probing is expensive and not appropriate for refreshes).</item>
|
||||
/// <item>If nothing resolves, store <see cref="VcpFeatureCodeMap.NotSupportedSentinel"/>.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="caps">Cap string data for the monitor.</param>
|
||||
/// <param name="maxCompatibilityMode">
|
||||
/// When <see langword="true"/>, Phase 2 probe fallback is enabled for features not
|
||||
/// found in the cap string.
|
||||
/// </param>
|
||||
/// <param name="persisted">
|
||||
/// Previously resolved map to reuse, or <see langword="null"/> for a fresh resolution.
|
||||
/// Only features already resolved in this map are reused; missing features are resolved
|
||||
/// fresh.
|
||||
/// </param>
|
||||
/// <param name="probe">
|
||||
/// Caller-supplied read-only VCP GET delegate. Invoked only in Phase 2. Must return
|
||||
/// <see langword="true"/> when the given code elicits a usable value from the monitor.
|
||||
/// </param>
|
||||
/// <returns>A fully resolved <see cref="VcpFeatureCodeMap"/> covering all features.</returns>
|
||||
public static VcpFeatureCodeMap Resolve(
|
||||
VcpCapabilities caps,
|
||||
bool maxCompatibilityMode,
|
||||
VcpFeatureCodeMap? persisted,
|
||||
Func<byte, bool> probe)
|
||||
{
|
||||
var map = new VcpFeatureCodeMap();
|
||||
|
||||
foreach (var feature in VcpFeatureRegistry.AllFeatures)
|
||||
{
|
||||
// Per-feature reuse: a persisted decision (code or sentinel) wins verbatim,
|
||||
// and is neither re-derived from caps nor re-probed.
|
||||
if (persisted != null && persisted.IsResolved(feature))
|
||||
{
|
||||
if (persisted.IsSupported(feature))
|
||||
{
|
||||
map.SetCode(feature, persisted.GetCode(feature));
|
||||
}
|
||||
else
|
||||
{
|
||||
map.SetNotSupported(feature);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidates = VcpFeatureRegistry.Candidates(feature);
|
||||
byte? resolved = null;
|
||||
|
||||
// Phase 1 (both modes): first candidate the cap string reports as supported.
|
||||
foreach (var code in candidates)
|
||||
{
|
||||
if (caps.SupportsVcpCode(code))
|
||||
{
|
||||
resolved = code;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 (max-compat only, fresh discovery, when Phase 1 found nothing): probe in priority order.
|
||||
// When a persisted map is supplied the caller is doing a cap-string refresh, not a
|
||||
// first-time discovery; probing is expensive/disruptive and is therefore skipped.
|
||||
if (resolved == null && maxCompatibilityMode && persisted == null)
|
||||
{
|
||||
foreach (var code in candidates)
|
||||
{
|
||||
if (probe(code))
|
||||
{
|
||||
resolved = code;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved.HasValue)
|
||||
{
|
||||
map.SetCode(feature, resolved.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
map.SetNotSupported(feature);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,19 @@ namespace PowerDisplay.Helpers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes the per-monitor persisted resolved-code maps onto the DDC/CI controller before
|
||||
/// discovery so already-resolved monitors are reused without re-probing. No-op if the DDC
|
||||
/// controller failed to initialize.
|
||||
/// </summary>
|
||||
public void SetPersistedVcpCodeMaps(IReadOnlyDictionary<string, VcpFeatureCodeMap> maps)
|
||||
{
|
||||
if (_ddcController != null)
|
||||
{
|
||||
_ddcController.PersistedVcpCodeMaps = maps;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover all monitors from all controllers.
|
||||
/// Each controller is responsible for fully initializing its monitors
|
||||
|
||||
@@ -34,6 +34,7 @@ namespace PowerDisplay.Serialization
|
||||
[JsonSerializable(typeof(List<VcpValueInfo>))]
|
||||
[JsonSerializable(typeof(List<PowerDisplayProfile>))]
|
||||
[JsonSerializable(typeof(List<ProfileMonitorSetting>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, int>))]
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = true,
|
||||
|
||||
@@ -10,6 +10,7 @@ using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Utils;
|
||||
using PowerDisplay.Helpers;
|
||||
using PowerDisplay.Models;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
@@ -31,6 +32,7 @@ public partial class MainViewModel
|
||||
// DDC/CI controller picks up toggle changes without a process restart.
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
_monitorManager.SetMaxCompatibilityMode(settings.Properties.MaxCompatibilityMode);
|
||||
PushPersistedVcpCodeMaps();
|
||||
|
||||
// Discover monitors
|
||||
var monitors = await _monitorManager.DiscoverMonitorsAsync(cancellationToken);
|
||||
@@ -115,6 +117,15 @@ public partial class MainViewModel
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
_monitorManager.SetMaxCompatibilityMode(settings.Properties.MaxCompatibilityMode);
|
||||
|
||||
// User-initiated Refresh only (not the display-change watcher): drop persisted
|
||||
// resolved-code maps so every feature is re-resolved from scratch.
|
||||
if (!skipScanningCheck)
|
||||
{
|
||||
_stateManager.ClearAllVcpCodeMaps();
|
||||
}
|
||||
|
||||
PushPersistedVcpCodeMaps();
|
||||
|
||||
var monitors = await _monitorManager.DiscoverMonitorsAsync(_cancellationTokenSource.Token);
|
||||
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
@@ -167,6 +178,15 @@ public partial class MainViewModel
|
||||
OnPropertyChanged(nameof(ShowLinkLevelsToggle));
|
||||
RecomputeLinkedBrightnessAvailability();
|
||||
|
||||
// Persist the freshly resolved feature->code maps (idempotent; debounced).
|
||||
foreach (var monitor in monitors)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(monitor.Id))
|
||||
{
|
||||
_stateManager.UpdateVcpCodeMap(monitor.Id, monitor.ResolvedVcpCodes);
|
||||
}
|
||||
}
|
||||
|
||||
// Save monitor information to settings
|
||||
SaveMonitorsToSettings();
|
||||
|
||||
@@ -191,4 +211,30 @@ public partial class MainViewModel
|
||||
.Where(m => m.IsHidden)
|
||||
.Select(m => m.Id),
|
||||
MonitorIdComparer.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-monitor persisted resolved-code maps from the state manager and pushes
|
||||
/// them onto the controller stack ahead of discovery. Empty maps force fresh resolution.
|
||||
/// </summary>
|
||||
private void PushPersistedVcpCodeMaps()
|
||||
{
|
||||
var maps = new Dictionary<string, VcpFeatureCodeMap>(MonitorIdComparer.Instance);
|
||||
|
||||
// Enumerate the settings monitor list (kept in sync with monitor_state.json by UpdateMonitorList) and look up each monitor's persisted resolved-code map; monitors without a persisted map simply resolve fresh.
|
||||
foreach (var existing in _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName).Properties.Monitors)
|
||||
{
|
||||
if (string.IsNullOrEmpty(existing.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var map = _stateManager.GetVcpCodeMap(existing.Id);
|
||||
if (map != null)
|
||||
{
|
||||
maps[existing.Id] = map;
|
||||
}
|
||||
}
|
||||
|
||||
_monitorManager.SetPersistedVcpCodeMaps(maps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +529,7 @@ public partial class MainViewModel
|
||||
// Monitor number for display name formatting
|
||||
MonitorNumber = vm.MonitorNumber,
|
||||
LastSeenUtc = _clock.UtcNow,
|
||||
ResolvedVcpCodes = vm.ResolvedVcpCodes.ToPersisted(),
|
||||
};
|
||||
|
||||
return monitorInfo;
|
||||
|
||||
@@ -288,6 +288,9 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
|
||||
public string? CapabilitiesRaw => _monitor.CapabilitiesRaw;
|
||||
|
||||
/// <summary>Gets the resolved per-feature VCP code map for this monitor.</summary>
|
||||
public VcpFeatureCodeMap ResolvedVcpCodes => _monitor.ResolvedVcpCodes;
|
||||
|
||||
public VcpCapabilities? VcpCapabilitiesInfo => _monitor.VcpCapabilitiesInfo;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
AdditionalActions = new();
|
||||
IsAIEnabled = false;
|
||||
ShowCustomPreview = true;
|
||||
ShowAIPaste = true;
|
||||
CloseAfterLosingFocus = false;
|
||||
EnableClipboardPreview = true;
|
||||
AutoCopySelectionForCustomActionHotkey = false;
|
||||
@@ -74,6 +75,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonConverter(typeof(BoolPropertyJsonConverter))]
|
||||
public bool ShowCustomPreview { get; set; }
|
||||
|
||||
[JsonConverter(typeof(BoolPropertyJsonConverter))]
|
||||
public bool ShowAIPaste { get; set; }
|
||||
|
||||
[JsonConverter(typeof(BoolPropertyJsonConverter))]
|
||||
public bool CloseAfterLosingFocus { get; set; }
|
||||
|
||||
@@ -107,10 +111,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[CmdConfigureIgnoreAttribute]
|
||||
public PasteAIConfiguration PasteAIConfiguration { get; set; }
|
||||
|
||||
[JsonPropertyName("python-scripts")]
|
||||
[CmdConfigureIgnoreAttribute]
|
||||
public AdvancedPastePythonScriptSettings PythonScripts { get; set; } = new();
|
||||
|
||||
public override string ToString()
|
||||
=> JsonSerializer.Serialize(this, SettingsSerializationContext.Default.AdvancedPasteProperties);
|
||||
}
|
||||
|
||||
@@ -1,298 +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.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPasteAction, ICloneable
|
||||
{
|
||||
private string _scriptPath = string.Empty;
|
||||
private string _name = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private bool _isShown = true;
|
||||
private bool _isEnabled = true;
|
||||
private string _platform = "windows";
|
||||
private string _formats = "any";
|
||||
private string _requires = string.Empty;
|
||||
private bool _requiresAutoDetect = true;
|
||||
private HotkeySettings _shortcut = new();
|
||||
private string _inputType = string.Empty;
|
||||
private string _outputType = string.Empty;
|
||||
|
||||
[JsonPropertyName("scriptPath")]
|
||||
public string ScriptPath
|
||||
{
|
||||
get => _scriptPath;
|
||||
set => Set(ref _scriptPath, value ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set
|
||||
{
|
||||
if (Set(ref _name, value ?? string.Empty))
|
||||
{
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Name if non-empty, otherwise falls back to the script filename without extension.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string DisplayName =>
|
||||
!string.IsNullOrWhiteSpace(_name)
|
||||
? _name
|
||||
: System.IO.Path.GetFileNameWithoutExtension(_scriptPath);
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description
|
||||
{
|
||||
get => _description;
|
||||
set => Set(ref _description, value ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonPropertyName("isShown")]
|
||||
public bool IsShown
|
||||
{
|
||||
get => _isShown;
|
||||
set => Set(ref _isShown, value);
|
||||
}
|
||||
|
||||
[JsonPropertyName("isEnabled")]
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set => Set(ref _isEnabled, value);
|
||||
}
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform
|
||||
{
|
||||
get => _platform;
|
||||
set => Set(ref _platform, value ?? "windows");
|
||||
}
|
||||
|
||||
[JsonPropertyName("formats")]
|
||||
public string Formats
|
||||
{
|
||||
get => _formats;
|
||||
set => Set(ref _formats, value ?? "any");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Space-separated requires entries, e.g. "cv2=opencv-python-headless numpy requests".
|
||||
/// Only written to header when RequiresAutoDetect is false (manual mode).
|
||||
/// </summary>
|
||||
[JsonPropertyName("requires")]
|
||||
public string Requires
|
||||
{
|
||||
get => _requires;
|
||||
set => Set(ref _requires, value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, dependencies are auto-detected from import statements.
|
||||
/// When false, the manual <see cref="Requires"/> value is used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiresAutoDetect")]
|
||||
public bool RequiresAutoDetect
|
||||
{
|
||||
get => _requiresAutoDetect;
|
||||
set => Set(ref _requiresAutoDetect, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverted view of RequiresAutoDetect for UI binding.
|
||||
/// Uses a separate field to avoid circular property change notifications.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsRequiresManual
|
||||
{
|
||||
get => !_requiresAutoDetect;
|
||||
set
|
||||
{
|
||||
var newAuto = !value;
|
||||
if (_requiresAutoDetect != newAuto)
|
||||
{
|
||||
_requiresAutoDetect = newAuto;
|
||||
OnPropertyChanged(nameof(RequiresAutoDetect));
|
||||
OnPropertyChanged(nameof(IsRequiresManual));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[JsonPropertyName("shortcut")]
|
||||
public HotkeySettings Shortcut
|
||||
{
|
||||
get => _shortcut;
|
||||
set
|
||||
{
|
||||
if (_shortcut != value)
|
||||
{
|
||||
_shortcut = value ?? new();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The input type declared in the function name (e.g. "text", "image", "audio").
|
||||
/// Read-only display property populated during script discovery.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string InputType
|
||||
{
|
||||
get => _inputType;
|
||||
set => Set(ref _inputType, value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The output type declared in the function name (e.g. "text", "image", "file").
|
||||
/// Read-only display property populated during script discovery.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string OutputType
|
||||
{
|
||||
get => _outputType;
|
||||
set => Set(ref _outputType, value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable conversion summary, e.g. "text → image".
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string ConversionSummary =>
|
||||
!string.IsNullOrEmpty(_inputType) && !string.IsNullOrEmpty(_outputType)
|
||||
? $"{_inputType} → {_outputType}"
|
||||
: string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public IEnumerable<IAdvancedPasteAction> SubActions => [];
|
||||
|
||||
// Convenience properties for format checkboxes
|
||||
[JsonIgnore]
|
||||
public bool SupportsText
|
||||
{
|
||||
get => FormatContains("text");
|
||||
set => ToggleFormat("text", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsHtml
|
||||
{
|
||||
get => FormatContains("html");
|
||||
set => ToggleFormat("html", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsImage
|
||||
{
|
||||
get => FormatContains("image");
|
||||
set => ToggleFormat("image", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsAudio
|
||||
{
|
||||
get => FormatContains("audio");
|
||||
set => ToggleFormat("audio", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsVideo
|
||||
{
|
||||
get => FormatContains("video");
|
||||
set => ToggleFormat("video", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsFiles
|
||||
{
|
||||
get => FormatContains("files") || FormatContains("file");
|
||||
set => ToggleFormat("files", value);
|
||||
}
|
||||
|
||||
private bool FormatContains(string format)
|
||||
{
|
||||
if (string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Formats.Split(',', StringSplitOptions.TrimEntries)
|
||||
.Any(f => string.Equals(f, format, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool _isTogglingFormat;
|
||||
|
||||
private void ToggleFormat(string format, bool include)
|
||||
{
|
||||
if (_isTogglingFormat)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTogglingFormat = true;
|
||||
try
|
||||
{
|
||||
var currentFormats = string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase)
|
||||
? new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(Formats.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Normalize file/files
|
||||
currentFormats.Remove("file");
|
||||
|
||||
if (include)
|
||||
{
|
||||
currentFormats.Add(format);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentFormats.Remove(format);
|
||||
}
|
||||
|
||||
var allFormats = new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase);
|
||||
Formats = currentFormats.SetEquals(allFormats) ? "any" : string.Join(", ", currentFormats);
|
||||
|
||||
OnPropertyChanged(nameof(SupportsText));
|
||||
OnPropertyChanged(nameof(SupportsHtml));
|
||||
OnPropertyChanged(nameof(SupportsImage));
|
||||
OnPropertyChanged(nameof(SupportsAudio));
|
||||
OnPropertyChanged(nameof(SupportsVideo));
|
||||
OnPropertyChanged(nameof(SupportsFiles));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isTogglingFormat = false;
|
||||
}
|
||||
}
|
||||
|
||||
public object Clone()
|
||||
{
|
||||
return new AdvancedPastePythonScriptAction
|
||||
{
|
||||
ScriptPath = ScriptPath,
|
||||
Name = Name,
|
||||
Description = Description,
|
||||
IsShown = IsShown,
|
||||
IsEnabled = IsEnabled,
|
||||
Platform = Platform,
|
||||
Formats = Formats,
|
||||
Requires = Requires,
|
||||
RequiresAutoDetect = RequiresAutoDetect,
|
||||
InputType = InputType,
|
||||
OutputType = OutputType,
|
||||
Shortcut = Shortcut != null ? new HotkeySettings(Shortcut.Win, Shortcut.Ctrl, Shortcut.Alt, Shortcut.Shift, Shortcut.Code) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,106 +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.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class AdvancedPastePythonScriptSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Execution mode: "disabled", "windows", or "wsl".
|
||||
/// </summary>
|
||||
[JsonPropertyName("mode")]
|
||||
public string Mode { get; set; } = "disabled";
|
||||
|
||||
/// <summary>
|
||||
/// Settings specific to Windows-native Python execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("windowsSettings")]
|
||||
public PythonScriptWindowsSettings WindowsSettings { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Settings specific to WSL Python execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("wslSettings")]
|
||||
public PythonScriptWslSettings WslSettings { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("timeoutSeconds")]
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public List<AdvancedPastePythonScriptAction> Value { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("trustedScriptHashes")]
|
||||
public Dictionary<string, string> TrustedScriptHashes { get; set; } = [];
|
||||
|
||||
// Legacy properties — read for migration, never written back
|
||||
[JsonPropertyName("isEnabled")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? IsEnabled { get; set; }
|
||||
|
||||
[JsonPropertyName("useWsl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? UseWsl { get; set; }
|
||||
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string ScriptsFolder { get; set; }
|
||||
|
||||
[JsonPropertyName("pythonExecutablePath")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string PythonExecutablePath { get; set; }
|
||||
|
||||
[JsonPropertyName("wslDistribution")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string WslDistribution { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Migrates legacy settings (isEnabled/useWsl) to new mode format on first load.
|
||||
/// </summary>
|
||||
public void MigrateLegacyIfNeeded()
|
||||
{
|
||||
// Only migrate if Mode hasn't been set by the new UI yet
|
||||
// (i.e., still at default "disabled") AND legacy fields are present.
|
||||
if (IsEnabled.HasValue && string.Equals(Mode, "disabled", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Migrate from old format
|
||||
if (!IsEnabled.Value)
|
||||
{
|
||||
Mode = "disabled";
|
||||
}
|
||||
else if (UseWsl == true)
|
||||
{
|
||||
Mode = "wsl";
|
||||
}
|
||||
else
|
||||
{
|
||||
Mode = "windows";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ScriptsFolder))
|
||||
{
|
||||
WindowsSettings.ScriptsFolder = ScriptsFolder;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(PythonExecutablePath))
|
||||
{
|
||||
WindowsSettings.PythonExecutablePath = PythonExecutablePath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(WslDistribution))
|
||||
{
|
||||
WslSettings.Distribution = WslDistribution;
|
||||
}
|
||||
}
|
||||
|
||||
// Always clear legacy fields so they don't persist
|
||||
IsEnabled = null;
|
||||
UseWsl = null;
|
||||
ScriptsFolder = null;
|
||||
PythonExecutablePath = null;
|
||||
WslDistribution = null;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,20 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
private System.DateTime? _lastSeenUtc;
|
||||
private string _capabilitiesRaw = string.Empty;
|
||||
private List<VcpCodeDisplayInfo> _vcpCodesFormatted = new List<VcpCodeDisplayInfo>();
|
||||
private Dictionary<string, int> _resolvedVcpCodes = new Dictionary<string, int>();
|
||||
|
||||
// Display order + labels for the diagnostics "Resolved feature" section. Keys must match
|
||||
// PowerDisplay.Lib VcpFeatureRegistry.Key(...) (Settings.UI.Library must not depend on .Lib).
|
||||
private static readonly (string Key, string Label)[] DiagnosticFeatureLabels =
|
||||
{
|
||||
("brightness", "Brightness"),
|
||||
("contrast", "Contrast"),
|
||||
("volume", "Volume"),
|
||||
("colorTemperature", "Color temperature"),
|
||||
("inputSource", "Input source"),
|
||||
("powerState", "Power state"),
|
||||
};
|
||||
|
||||
private int _monitorNumber;
|
||||
private int _totalMonitorCount;
|
||||
|
||||
@@ -387,6 +401,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the resolved feature-to-VCP-code map (feature name -> code, or -1 =
|
||||
/// checked-but-unsupported). Written by the PowerDisplay app; shown in diagnostics.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resolvedVcpCodes")]
|
||||
public Dictionary<string, int> ResolvedVcpCodes
|
||||
{
|
||||
get => _resolvedVcpCodes;
|
||||
set
|
||||
{
|
||||
_resolvedVcpCodes = value ?? new Dictionary<string, int>();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare two VcpCodesFormatted lists for equality by content.
|
||||
/// Returns true if both lists have the same VCP codes (by code value).
|
||||
@@ -710,6 +739,22 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
lines.Add($"Supports power state: {SupportsPowerState}");
|
||||
lines.Add(string.Empty);
|
||||
|
||||
lines.Add("Resolved feature -> VCP code");
|
||||
lines.Add(new string('-', 50));
|
||||
foreach (var (key, label) in DiagnosticFeatureLabels)
|
||||
{
|
||||
if (_resolvedVcpCodes.TryGetValue(key, out var code))
|
||||
{
|
||||
lines.Add(code < 0 ? $"{label}: not supported" : $"{label}: 0x{code:X2}");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"{label}: not resolved");
|
||||
}
|
||||
}
|
||||
|
||||
lines.Add(string.Empty);
|
||||
|
||||
lines.Add("Raw capabilities");
|
||||
lines.Add(new string('-', 50));
|
||||
lines.Add(string.IsNullOrWhiteSpace(CapabilitiesRaw) ? "No raw capabilities detected" : CapabilitiesRaw);
|
||||
@@ -749,6 +794,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
EnablePowerState = other.EnablePowerState;
|
||||
CapabilitiesRaw = other.CapabilitiesRaw;
|
||||
VcpCodesFormatted = other.VcpCodesFormatted;
|
||||
ResolvedVcpCodes = other.ResolvedVcpCodes;
|
||||
SupportsBrightness = other.SupportsBrightness;
|
||||
SupportsContrast = other.SupportsContrast;
|
||||
SupportsColorTemperature = other.SupportsColorTemperature;
|
||||
|
||||
@@ -1,16 +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.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class PythonScriptWindowsSettings
|
||||
{
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
public string ScriptsFolder { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("pythonExecutablePath")]
|
||||
public string PythonExecutablePath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,16 +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.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class PythonScriptWslSettings
|
||||
{
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
public string ScriptsFolder { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("distribution")]
|
||||
public string Distribution { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -140,14 +140,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonSerializable(typeof(AdvancedPasteAdditionalAction))]
|
||||
[JsonSerializable(typeof(AdvancedPastePasteAsFileAction))]
|
||||
[JsonSerializable(typeof(AdvancedPasteTranscodeAction))]
|
||||
[JsonSerializable(typeof(AdvancedPastePythonScriptSettings))]
|
||||
[JsonSerializable(typeof(AdvancedPastePythonScriptAction))]
|
||||
[JsonSerializable(typeof(PythonScriptWindowsSettings))]
|
||||
[JsonSerializable(typeof(PythonScriptWslSettings))]
|
||||
[JsonSerializable(typeof(ImageResizerSizes))]
|
||||
[JsonSerializable(typeof(ImageResizerCustomSizeProperty))]
|
||||
[JsonSerializable(typeof(KeyboardKeysProperty))]
|
||||
[JsonSerializable(typeof(MonitorInfo))]
|
||||
[JsonSerializable(typeof(Dictionary<string, int>))]
|
||||
[JsonSerializable(typeof(PowerDisplayActionMessage))]
|
||||
[JsonSerializable(typeof(PowerDisplayActionMessage.ActionData))]
|
||||
[JsonSerializable(typeof(PowerDisplayActionMessage.PowerDisplayAction))]
|
||||
|
||||
@@ -1,59 +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.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.ViewModels;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ViewModelTests
|
||||
{
|
||||
[TestClass]
|
||||
public class AdvancedPaste
|
||||
{
|
||||
[TestMethod]
|
||||
public void CreateActionFromScript_ExtractsConversionTypes()
|
||||
{
|
||||
var scriptPath = Path.Combine(Path.GetTempPath(), $"AdvancedPaste-{Guid.NewGuid():N}.py");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllLines(
|
||||
scriptPath,
|
||||
[
|
||||
"# @advancedpaste:name transcribe audio",
|
||||
"# @advancedpaste:desc Transcribes audio to text",
|
||||
string.Empty,
|
||||
"def advanced_paste_from_audio_to_text(audio_path):",
|
||||
" return 'transcribed'",
|
||||
]);
|
||||
|
||||
var createActionFromScript = typeof(AdvancedPasteViewModel)
|
||||
.GetMethod("CreateActionFromScript", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.IsNotNull(createActionFromScript);
|
||||
|
||||
var action = (AdvancedPastePythonScriptAction)createActionFromScript.Invoke(
|
||||
null, [scriptPath, new System.Collections.Generic.List<AdvancedPastePythonScriptAction>()]);
|
||||
|
||||
Assert.AreEqual("transcribe audio", action.Name);
|
||||
Assert.AreEqual("Transcribes audio to text", action.Description);
|
||||
Assert.AreEqual("audio", action.InputType);
|
||||
Assert.AreEqual("text", action.OutputType);
|
||||
Assert.AreEqual("audio → text", action.ConversionSummary);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(scriptPath))
|
||||
{
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext;
|
||||
[JsonSerializable(typeof(PowerOcrSettings))]
|
||||
[JsonSerializable(typeof(PowerOcrSettings))]
|
||||
[JsonSerializable(typeof(PowerDisplaySettings))]
|
||||
[JsonSerializable(typeof(Dictionary<string, int>))]
|
||||
[JsonSerializable(typeof(RegistryPreviewSettings))]
|
||||
[JsonSerializable(typeof(ShortcutConflictProperties))]
|
||||
[JsonSerializable(typeof(ShortcutGuideSettings))]
|
||||
|
||||
@@ -164,10 +164,10 @@
|
||||
</InfoBar.IconSource>
|
||||
</InfoBar>
|
||||
</tkcontrols:SettingsExpander.ItemsHeader>
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.AdvancedPasteUIShortcut, Mode=TwoWay}" />
|
||||
<controls:ShortcutControl HotkeySettings="{x:Bind Path=ViewModel.AdvancedPasteUIShortcut, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteEnableClipboardPreview" ContentAlignment="Left">
|
||||
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_EnableClipboardPreview" IsChecked="{x:Bind ViewModel.EnableClipboardPreview, Mode=TwoWay}" />
|
||||
<CheckBox x:Uid="AdvancedPaste_EnableClipboardPreview" IsChecked="{x:Bind ViewModel.EnableClipboardPreview, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteAutoCopySelectionCustomAction" ContentAlignment="Left">
|
||||
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_AutoCopySelectionForCustomActionHotkey" IsChecked="{x:Bind ViewModel.AutoCopySelectionForCustomActionHotkey, Mode=TwoWay}" />
|
||||
@@ -178,13 +178,15 @@
|
||||
IsEnabled="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=OneWay}">
|
||||
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_Clipboard_History_Enabled_SettingsCard" IsChecked="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteCloseAfterLosingFocus" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="AdvancedPaste_CloseAfterLosingFocus" IsChecked="{x:Bind ViewModel.CloseAfterLosingFocus, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteShowCustomPreviewSettingsCard" ContentAlignment="Left">
|
||||
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_ShowCustomPreviewSettingsCard" IsChecked="{x:Bind ViewModel.ShowCustomPreview, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteShowAIPasteSettingsCard" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="AdvancedPaste_ShowAIPasteSettingsCard" IsChecked="{x:Bind ViewModel.ShowAIPaste, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
@@ -195,7 +197,7 @@
|
||||
Name="PasteAsPlainTextShortcut"
|
||||
x:Uid="PasteAsPlainText_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.PasteAsPlainTextShortcut, Mode=TwoWay}" />
|
||||
<controls:ShortcutControl HotkeySettings="{x:Bind Path=ViewModel.PasteAsPlainTextShortcut, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="PasteAsMarkdownShortcut"
|
||||
@@ -390,138 +392,6 @@
|
||||
IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}"
|
||||
Severity="Warning" />
|
||||
</controls:SettingsGroup>
|
||||
|
||||
<controls:SettingsGroup x:Uid="AdvancedPaste_PythonScripts_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<InfoBar
|
||||
x:Name="PythonScriptsGpoInfoBar"
|
||||
x:Uid="GPO_SettingIsManaged"
|
||||
IsClosable="False"
|
||||
IsOpen="{x:Bind ViewModel.ShowPythonScriptsGpoConfiguredInfoBar, Mode=OneWay}"
|
||||
IsTabStop="{x:Bind ViewModel.ShowPythonScriptsGpoConfiguredInfoBar, Mode=OneWay}"
|
||||
Severity="Informational">
|
||||
<InfoBar.IconSource>
|
||||
<FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" />
|
||||
</InfoBar.IconSource>
|
||||
</InfoBar>
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonScripts_RuntimeCard">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<ComboBox
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
IsEnabled="{x:Bind ViewModel.IsPythonScriptsDisallowedByGPO, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
|
||||
SelectedIndex="{x:Bind ViewModel.PythonScriptsModeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="AdvancedPaste_PythonScripts_ModeOff" />
|
||||
<ComboBoxItem x:Uid="AdvancedPaste_PythonScripts_ModeWindows" />
|
||||
<ComboBoxItem x:Uid="AdvancedPaste_PythonScripts_ModeWsl" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_ScriptsFolder_SettingsCard" IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox
|
||||
x:Name="ScriptsFolderTextBox"
|
||||
x:Uid="AdvancedPaste_ScriptsFolder_TextBox"
|
||||
MinWidth="300"
|
||||
HorizontalAlignment="Stretch"
|
||||
Text="{x:Bind ViewModel.ScriptsFolder, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
|
||||
<Button
|
||||
VerticalAlignment="Bottom"
|
||||
Click="BrowseScriptsFolder_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
<Button
|
||||
x:Name="OpenScriptsFolderButton"
|
||||
VerticalAlignment="Bottom"
|
||||
Click="OpenScriptsFolder_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- Windows-mode settings -->
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonExecutablePath_SettingsCard" Visibility="{x:Bind ViewModel.IsWindowsMode, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox
|
||||
x:Name="PythonExecutablePathTextBox"
|
||||
x:Uid="AdvancedPaste_PythonExecutablePath_TextBox"
|
||||
MinWidth="300"
|
||||
HorizontalAlignment="Stretch"
|
||||
Text="{x:Bind ViewModel.PythonExecutablePath, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
|
||||
<Button
|
||||
VerticalAlignment="Bottom"
|
||||
Click="BrowsePythonExecutablePath_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- WSL-mode settings -->
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_WslDistribution_SettingsCard" Visibility="{x:Bind ViewModel.IsWslMode, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<ComboBox
|
||||
MinWidth="200"
|
||||
ItemsSource="{x:Bind ViewModel.WslDistroDisplayNames, Mode=OneWay}"
|
||||
SelectedIndex="{x:Bind ViewModel.WslDistributionIndex, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- Discovered Python Scripts (read-only) -->
|
||||
<tkcontrols:SettingsExpander
|
||||
x:Name="PythonScriptListExpander"
|
||||
x:Uid="AdvancedPaste_PythonScriptList"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}"
|
||||
IsExpanded="True"
|
||||
ItemsSource="{x:Bind ViewModel.PythonScriptActions, Mode=OneWay}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="RefreshScriptsButton"
|
||||
x:Uid="AdvancedPaste_PythonScripts_LoadScripts"
|
||||
Click="RefreshPythonScripts_Click"
|
||||
Style="{ThemeResource AccentButtonStyle}" />
|
||||
</StackPanel>
|
||||
<tkcontrols:SettingsExpander.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:AdvancedPastePythonScriptAction">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,2"
|
||||
Description="{x:Bind Description, Mode=OneWay}"
|
||||
Header="{x:Bind DisplayName, Mode=OneWay}"
|
||||
IsActionIconVisible="False">
|
||||
<StackPanel
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="12">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ConversionSummary, Mode=OneWay}" />
|
||||
<Button
|
||||
Click="OpenPythonScript_Click"
|
||||
Content=""
|
||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Tag="{x:Bind}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="AdvancedPaste_PythonScripts_OpenScriptInEditor" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
</DataTemplate>
|
||||
</tkcontrols:SettingsExpander.ItemTemplate>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
</StackPanel>
|
||||
</controls:SettingsPageControl.ModuleContent>
|
||||
<controls:SettingsPageControl.PrimaryLinks>
|
||||
@@ -560,6 +430,7 @@
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
|
||||
<!-- Paste AI provider dialog -->
|
||||
<ContentDialog
|
||||
x:Name="PasteAIProviderConfigurationDialog"
|
||||
x:Uid="AdvancedPaste_EndpointDialog"
|
||||
|
||||
@@ -65,16 +65,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
ViewModel.OnPageLoaded();
|
||||
UpdatePasteAIUIVisibility();
|
||||
await UpdateFoundryLocalUIAsync();
|
||||
|
||||
if (ViewModel.IsWslMode)
|
||||
{
|
||||
ViewModel.RefreshWslDistros();
|
||||
}
|
||||
|
||||
if (OpenScriptsFolderButton is not null)
|
||||
{
|
||||
ToolTipService.SetToolTip(OpenScriptsFolderButton, ResourceLoaderInstance.ResourceLoader.GetString("AdvancedPaste_PythonScripts_OpenFolder"));
|
||||
}
|
||||
};
|
||||
|
||||
Unloaded += (_, _) =>
|
||||
@@ -274,75 +264,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowsePythonExecutablePath_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string selectedFile = PickFileDialog(
|
||||
"Python Executable\0python.exe;python3.exe\0All Executables\0*.exe\0",
|
||||
"Select Python Executable");
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedFile))
|
||||
{
|
||||
PythonExecutablePathTextBox.Text = selectedFile;
|
||||
if (ViewModel is not null)
|
||||
{
|
||||
ViewModel.PythonExecutablePath = selectedFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowseScriptsFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow());
|
||||
string selectedFolder = ShellGetFolder.GetFolderDialogWithFlags(
|
||||
windowHandle,
|
||||
ShellGetFolder.FolderDialogFlags._BIF_NEWDIALOGSTYLE);
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedFolder))
|
||||
{
|
||||
ScriptsFolderTextBox.Text = selectedFolder;
|
||||
if (ViewModel is not null)
|
||||
{
|
||||
ViewModel.ScriptsFolder = selectedFolder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenScriptsFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var folder = ViewModel?.ScriptsFolder;
|
||||
if (!string.IsNullOrEmpty(folder) && System.IO.Directory.Exists(folder))
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = folder,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshPythonScripts_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel?.RefreshPythonScripts();
|
||||
RefreshScriptsButton.Content = ResourceLoaderInstance.ResourceLoader.GetString("AdvancedPaste_PythonScript_RefreshScripts/Content");
|
||||
}
|
||||
|
||||
private void OpenPythonScript_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: AdvancedPastePythonScriptAction action })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (System.IO.File.Exists(action.ScriptPath))
|
||||
{
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo(action.ScriptPath)
|
||||
{
|
||||
UseShellExecute = true,
|
||||
};
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PickFileDialog(string filter, string title, string initialDir = null, int initialFilter = 0)
|
||||
{
|
||||
// Use Win32 OpenFileName dialog as FileOpenPicker doesn't work with elevated permissions
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
@@ -6230,77 +6230,4 @@ Text uses the current drawing color.</value>
|
||||
<value>Example: outlook</value>
|
||||
<comment>{Locked="outlook"}</comment>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_GroupSettings.Header" xml:space="preserve">
|
||||
<value>Python scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_RuntimeCard.Header" xml:space="preserve">
|
||||
<value>Python runtime</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_RuntimeCard.Description" xml:space="preserve">
|
||||
<value>Choose how to run custom Python scripts from the Advanced Paste menu</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_ScriptsFolder_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Scripts folder</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_ScriptsFolder_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Folder to scan for Python scripts (.py files). Leave blank to use the default location.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_ScriptsFolder_TextBox.PlaceholderText" xml:space="preserve">
|
||||
<value>Default (%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts)</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonExecutablePath_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Python interpreter</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonExecutablePath_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Path to the Python executable used to run scripts. Leave blank to detect automatically (supports Anaconda, Miniconda, system Python).</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonExecutablePath_TextBox.PlaceholderText" xml:space="preserve">
|
||||
<value>Auto-detect (e.g. C:\Users\<user>\anaconda3\python.exe)</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_UseWsl_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Use WSL</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_UseWsl_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Run Python scripts in Windows Subsystem for Linux instead of native Windows Python.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_WslDistribution_SettingsCard.Header" xml:space="preserve">
|
||||
<value>WSL distribution</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_WslDistribution_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Select which WSL distribution to use for running Python scripts.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScriptList.Header" xml:space="preserve">
|
||||
<value>Discovered scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScriptList.Description" xml:space="preserve">
|
||||
<value>Python scripts found in the scripts folder.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScript_RefreshScripts.Content" xml:space="preserve">
|
||||
<value>Refresh scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScript_ApplyChanges.Content" xml:space="preserve">
|
||||
<value>Apply changes</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_ModeOff.Content" xml:space="preserve">
|
||||
<value>Off</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_ModeWindows.Content" xml:space="preserve">
|
||||
<value>Windows Python</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_ModeWsl.Content" xml:space="preserve">
|
||||
<value>WSL Python</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_LoadScripts.Content" xml:space="preserve">
|
||||
<value>Load scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_OpenFolder" xml:space="preserve">
|
||||
<value>Open folder</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_OpenScriptInEditor.Text" xml:space="preserve">
|
||||
<value>Open script in editor</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_WslDefaultDistro" xml:space="preserve">
|
||||
<value>(System default)</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
@@ -51,7 +51,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
private bool _enabledStateIsGPOConfigured;
|
||||
private GpoRuleConfigured _onlineAIModelsGpoRuleConfiguration;
|
||||
private bool _onlineAIModelsDisallowedByGPO;
|
||||
private bool _pythonScriptsDisallowedByGPO;
|
||||
private bool _isEnabled;
|
||||
|
||||
private Func<string, int> SendConfigMSG { get; }
|
||||
@@ -177,18 +176,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
// disable AI if it was enabled
|
||||
DisableAI();
|
||||
}
|
||||
|
||||
_pythonScriptsDisallowedByGPO = GPOWrapper.GetAllowedAdvancedPastePythonScriptsValue() == GpoRuleConfigured.Disabled;
|
||||
|
||||
if (_pythonScriptsDisallowedByGPO)
|
||||
{
|
||||
// Force Python scripts to disabled mode when blocked by GPO
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
if (scripts != null && !string.Equals(scripts.Mode, "disabled", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.Mode = "disabled";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void MigrateLegacyAIEnablement()
|
||||
@@ -306,445 +293,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions;
|
||||
|
||||
public bool IsPythonScriptsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
return scripts != null && !string.Equals(scripts.Mode, "disabled", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ComboBox index: 0 = Disabled, 1 = Windows, 2 = WSL.
|
||||
/// </summary>
|
||||
public int PythonScriptsModeIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
var mode = scripts?.Mode ?? "disabled";
|
||||
return mode switch
|
||||
{
|
||||
"windows" => 1,
|
||||
"wsl" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_pythonScriptsDisallowedByGPO)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
var newMode = value switch
|
||||
{
|
||||
1 => "windows",
|
||||
2 => "wsl",
|
||||
_ => "disabled",
|
||||
};
|
||||
|
||||
if (!string.Equals(scripts.Mode, newMode, StringComparison.Ordinal))
|
||||
{
|
||||
scripts.Mode = newMode;
|
||||
OnPropertyChanged(nameof(PythonScriptsModeIndex));
|
||||
OnPropertyChanged(nameof(IsPythonScriptsEnabled));
|
||||
OnPropertyChanged(nameof(IsWindowsMode));
|
||||
OnPropertyChanged(nameof(IsWslMode));
|
||||
OnPropertyChanged(nameof(ScriptsFolder));
|
||||
OnPropertyChanged(nameof(PythonExecutablePath));
|
||||
OnPropertyChanged(nameof(WslDistribution));
|
||||
SaveAndNotifySettings();
|
||||
|
||||
if (newMode == "wsl")
|
||||
{
|
||||
RefreshWslDistros();
|
||||
}
|
||||
|
||||
if (_scriptsDiscovered)
|
||||
{
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsWindowsMode => PythonScriptsModeIndex == 1;
|
||||
|
||||
public bool IsWslMode => PythonScriptsModeIndex == 2;
|
||||
|
||||
public bool ScriptsDiscovered => _scriptsDiscovered;
|
||||
|
||||
public string PythonExecutablePath
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.PythonScripts?.WindowsSettings?.PythonExecutablePath ?? string.Empty;
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
scripts.WindowsSettings ??= new PythonScriptWindowsSettings();
|
||||
if (!string.Equals(scripts.WindowsSettings.PythonExecutablePath, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WindowsSettings.PythonExecutablePath = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(PythonExecutablePath));
|
||||
SaveAndNotifySettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string WslDistribution
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.PythonScripts?.WslSettings?.Distribution ?? string.Empty;
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
scripts.WslSettings ??= new PythonScriptWslSettings();
|
||||
if (!string.Equals(scripts.WslSettings.Distribution, value, StringComparison.Ordinal))
|
||||
{
|
||||
scripts.WslSettings.Distribution = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(WslDistribution));
|
||||
OnPropertyChanged(nameof(WslDistributionIndex));
|
||||
SaveAndNotifySettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> _availableWslDistros = [string.Empty];
|
||||
|
||||
/// <summary>
|
||||
/// Available WSL distributions. First item is "" (system default).
|
||||
/// </summary>
|
||||
public List<string> AvailableWslDistros
|
||||
{
|
||||
get => _availableWslDistros;
|
||||
private set
|
||||
{
|
||||
_availableWslDistros = value;
|
||||
OnPropertyChanged(nameof(AvailableWslDistros));
|
||||
OnPropertyChanged(nameof(WslDistroDisplayNames));
|
||||
OnPropertyChanged(nameof(WslDistributionIndex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display names for the ComboBox (maps empty string to "(System default)").
|
||||
/// </summary>
|
||||
public List<string> WslDistroDisplayNames =>
|
||||
_availableWslDistros.Select(d => string.IsNullOrEmpty(d) ? ResourceLoaderInstance.ResourceLoader.GetString("AdvancedPaste_PythonScripts_WslDefaultDistro") : d).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Selected index into AvailableWslDistros for ComboBox binding.
|
||||
/// </summary>
|
||||
public int WslDistributionIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
var current = WslDistribution;
|
||||
var idx = _availableWslDistros.IndexOf(current);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (value >= 0 && value < _availableWslDistros.Count)
|
||||
{
|
||||
WslDistribution = _availableWslDistros[value];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries installed WSL distributions and populates AvailableWslDistros.
|
||||
/// </summary>
|
||||
public void RefreshWslDistros()
|
||||
{
|
||||
var distros = new List<string> { string.Empty }; // first = system default
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo("wsl.exe", "-l -q")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
StandardOutputEncoding = System.Text.Encoding.Unicode,
|
||||
};
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(psi);
|
||||
if (process != null)
|
||||
{
|
||||
// WaitForExit first with timeout to avoid blocking indefinitely on ReadToEnd().
|
||||
// If the process doesn't finish in time, kill it and wait for exit before reading.
|
||||
if (!process.WaitForExit(5000))
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
// After Kill, wait briefly for the process to actually terminate.
|
||||
// If it still hasn't exited, skip reading but still update with default-only list.
|
||||
if (!process.WaitForExit(2000))
|
||||
{
|
||||
AvailableWslDistros = distros;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
|
||||
var names = output
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Trim().Trim('\0', '\r'))
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name));
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
distros.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// WSL not available — just show the default option
|
||||
}
|
||||
|
||||
AvailableWslDistros = distros;
|
||||
}
|
||||
|
||||
public string ScriptsFolder
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
if (scripts == null)
|
||||
{
|
||||
return DefaultScriptsFolder;
|
||||
}
|
||||
|
||||
var folder = string.Equals(scripts.Mode, "wsl", StringComparison.OrdinalIgnoreCase)
|
||||
? scripts.WslSettings?.ScriptsFolder
|
||||
: scripts.WindowsSettings?.ScriptsFolder;
|
||||
|
||||
return string.IsNullOrWhiteSpace(folder) ? DefaultScriptsFolder : folder;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
if (string.Equals(scripts.Mode, "wsl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WslSettings ??= new PythonScriptWslSettings();
|
||||
if (!string.Equals(scripts.WslSettings.ScriptsFolder, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WslSettings.ScriptsFolder = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(ScriptsFolder));
|
||||
SaveAndNotifySettings();
|
||||
|
||||
if (_scriptsDiscovered)
|
||||
{
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
scripts.WindowsSettings ??= new PythonScriptWindowsSettings();
|
||||
if (!string.Equals(scripts.WindowsSettings.ScriptsFolder, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WindowsSettings.ScriptsFolder = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(ScriptsFolder));
|
||||
SaveAndNotifySettings();
|
||||
|
||||
if (_scriptsDiscovered)
|
||||
{
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string DefaultScriptsFolder { get; } = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"AdvancedPaste",
|
||||
"Scripts");
|
||||
|
||||
private ObservableCollection<AdvancedPastePythonScriptAction> _pythonScriptActions = [];
|
||||
private bool _scriptsDiscovered;
|
||||
|
||||
public ObservableCollection<AdvancedPastePythonScriptAction> PythonScriptActions
|
||||
{
|
||||
get => _pythonScriptActions;
|
||||
set
|
||||
{
|
||||
_pythonScriptActions = value;
|
||||
OnPropertyChanged(nameof(PythonScriptActions));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the scripts folder for .py files and populates PythonScriptActions
|
||||
/// with metadata from their headers. Existing settings (hotkeys) are preserved.
|
||||
/// </summary>
|
||||
public void RefreshPythonScripts()
|
||||
{
|
||||
_scriptsDiscovered = true;
|
||||
OnPropertyChanged(nameof(ScriptsDiscovered));
|
||||
|
||||
var folder = ScriptsFolder;
|
||||
if (string.IsNullOrWhiteSpace(folder) || !System.IO.Directory.Exists(folder))
|
||||
{
|
||||
PythonScriptActions = [];
|
||||
return;
|
||||
}
|
||||
|
||||
var scripts = new ObservableCollection<AdvancedPastePythonScriptAction>();
|
||||
var savedActions = _advancedPasteSettings.Properties.PythonScripts?.Value ?? [];
|
||||
|
||||
foreach (var file in System.IO.Directory.EnumerateFiles(folder, "*.py", System.IO.SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = CreateActionFromScript(file, savedActions);
|
||||
|
||||
// Only list scripts that define exactly one advanced_paste_from_*_to_*() function,
|
||||
// matching the runtime's discovery behavior in PythonScriptService.
|
||||
if (string.IsNullOrEmpty(action.InputType) || string.IsNullOrEmpty(action.OutputType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
scripts.Add(action);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip scripts that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
PythonScriptActions = scripts;
|
||||
}
|
||||
|
||||
private static AdvancedPastePythonScriptAction CreateActionFromScript(
|
||||
string filePath,
|
||||
List<AdvancedPastePythonScriptAction> savedActions)
|
||||
{
|
||||
// Read header metadata from the .py file
|
||||
var name = System.IO.Path.GetFileNameWithoutExtension(filePath);
|
||||
var description = string.Empty;
|
||||
var platform = "windows";
|
||||
var formats = "any";
|
||||
var enabled = true;
|
||||
var requires = string.Empty;
|
||||
var hasExplicitRequires = false;
|
||||
var inputType = string.Empty;
|
||||
var outputType = string.Empty;
|
||||
int matchingFunctionCount = 0;
|
||||
|
||||
using var reader = new System.IO.StreamReader(filePath, System.Text.Encoding.UTF8);
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Detect the function definition to extract input/output types
|
||||
if (trimmed.StartsWith("def advanced_paste_from_", StringComparison.Ordinal))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
trimmed,
|
||||
@"^def advanced_paste_from_(text|html|image|audio|video|files)_to_(text|html|image|audio|video|file|files)\s*\(");
|
||||
if (match.Success)
|
||||
{
|
||||
matchingFunctionCount++;
|
||||
inputType = match.Groups[1].Value;
|
||||
outputType = match.Groups[2].Value;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseTag(trimmed, "@advancedpaste:name", out var val))
|
||||
{
|
||||
name = val;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:desc", out val))
|
||||
{
|
||||
description = val;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:platform", out val))
|
||||
{
|
||||
platform = val.ToLowerInvariant();
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:formats", out val))
|
||||
{
|
||||
formats = val;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:disabled", out _, presenceBased: true))
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:requires", out val))
|
||||
{
|
||||
// Accumulate multiple requires tags
|
||||
requires = string.IsNullOrEmpty(requires) ? val : $"{requires} {val}";
|
||||
hasExplicitRequires = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime rejects scripts with more than one matching function.
|
||||
if (matchingFunctionCount != 1)
|
||||
{
|
||||
inputType = string.Empty;
|
||||
outputType = string.Empty;
|
||||
}
|
||||
|
||||
// Preserve existing saved settings (hotkeys, IsShown)
|
||||
var saved = savedActions?.FirstOrDefault(a =>
|
||||
string.Equals(a.ScriptPath, filePath, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return new AdvancedPastePythonScriptAction
|
||||
{
|
||||
ScriptPath = filePath,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Platform = platform,
|
||||
Formats = formats,
|
||||
IsEnabled = enabled,
|
||||
Requires = requires,
|
||||
RequiresAutoDetect = !hasExplicitRequires,
|
||||
InputType = inputType,
|
||||
OutputType = outputType,
|
||||
IsShown = saved?.IsShown ?? true,
|
||||
Shortcut = saved?.Shortcut ?? new HotkeySettings(),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseTag(string line, string tag, out string value, bool presenceBased = false)
|
||||
{
|
||||
var idx = line.IndexOf(tag, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = line[(idx + tag.Length)..].Trim();
|
||||
return presenceBased || value.Length > 0;
|
||||
}
|
||||
|
||||
public static IEnumerable<AIServiceTypeMetadata> AvailableProviders => AIServiceTypeRegistry.GetAvailableServiceTypes();
|
||||
|
||||
/// <summary>
|
||||
@@ -798,16 +346,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
get => _onlineAIModelsDisallowedByGPO && _isEnabled;
|
||||
}
|
||||
|
||||
public bool IsPythonScriptsDisallowedByGPO
|
||||
{
|
||||
get => _pythonScriptsDisallowedByGPO || _enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled;
|
||||
}
|
||||
|
||||
public bool ShowPythonScriptsGpoConfiguredInfoBar
|
||||
{
|
||||
get => _pythonScriptsDisallowedByGPO && _isEnabled;
|
||||
}
|
||||
|
||||
private bool IsClipboardHistoryEnabled()
|
||||
{
|
||||
string registryKey = @"HKEY_CURRENT_USER\Software\Microsoft\Clipboard\";
|
||||
@@ -1005,6 +543,19 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowAIPaste
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.ShowAIPaste;
|
||||
set
|
||||
{
|
||||
if (value != _advancedPasteSettings.Properties.ShowAIPaste)
|
||||
{
|
||||
_advancedPasteSettings.Properties.ShowAIPaste = value;
|
||||
NotifySettingsChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool CloseAfterLosingFocus
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.CloseAfterLosingFocus;
|
||||
@@ -1696,6 +1247,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
OnPropertyChanged(nameof(ShowCustomPreview));
|
||||
}
|
||||
|
||||
if (target.ShowAIPaste != source.ShowAIPaste)
|
||||
{
|
||||
target.ShowAIPaste = source.ShowAIPaste;
|
||||
OnPropertyChanged(nameof(ShowAIPaste));
|
||||
}
|
||||
|
||||
if (target.CloseAfterLosingFocus != source.CloseAfterLosingFocus)
|
||||
{
|
||||
target.CloseAfterLosingFocus = source.CloseAfterLosingFocus;
|
||||
|
||||
Reference in New Issue
Block a user