Compare commits

..

16 Commits

Author SHA1 Message Date
Yu Leng (from Dev Box)
c4ec92bb8f [PowerDisplay] Polish: harden VcpFeatureCodeMap, refresh DDC docs, clarify orchestration 2026-06-30 12:50:20 +08:00
Yu Leng (from Dev Box)
450dd5dd59 [PowerDisplay] Show resolved feature->VCP code in diagnostics 2026-06-30 12:37:12 +08:00
Yu Leng (from Dev Box)
53fc27b552 [PowerDisplay] Document MonitorViewModel.ResolvedVcpCodes 2026-06-30 12:25:30 +08:00
Yu Leng (from Dev Box)
0d5eb84d4f [PowerDisplay] Wire persisted VCP code maps through discovery/refresh 2026-06-30 12:21:32 +08:00
Yu Leng (from Dev Box)
8e8e4408ae [PowerDisplay] Resolve and use per-feature VCP codes in DDC controller 2026-06-30 12:15:46 +08:00
Yu Leng (from Dev Box)
9c762c720c [PowerDisplay] Add get/update/clear APIs for resolved VCP code maps 2026-06-30 12:09:06 +08:00
Yu Leng (from Dev Box)
f8bf1be06b [PowerDisplay] Persist resolved VCP codes in monitor state 2026-06-30 12:04:58 +08:00
Yu Leng (from Dev Box)
b0059e2c1d [PowerDisplay] Tighten VcpFeatureResolver caps contract and resolver tests 2026-06-30 12:01:35 +08:00
Yu Leng (from Dev Box)
a81effce10 [PowerDisplay] Spec: clarify Phase 2 probe runs only on fresh discovery (persisted==null) 2026-06-30 11:58:09 +08:00
Yu Leng (from Dev Box)
5933cb9ece [PowerDisplay] Add VcpFeatureResolver (cap-string-first, probe fallback) 2026-06-30 11:55:21 +08:00
Yu Leng (from Dev Box)
bf81179b35 [PowerDisplay] Add VcpFeatureCodeMap with persistence round-trip 2026-06-30 11:47:21 +08:00
Yu Leng (from Dev Box)
6e7232862f [PowerDisplay] Document VcpFeatureRegistry API; fix stale brightness comment 2026-06-30 11:43:11 +08:00
Yu Leng (from Dev Box)
0f434b5344 [PowerDisplay] Add VcpFeature enum and candidate-code registry 2026-06-30 11:38:32 +08:00
Yu Leng (from Dev Box)
d7916e0457 [PowerDisplay] Plan: use MSBuild/vstest toolchain (not dotnet CLI) 2026-06-30 11:35:10 +08:00
Yu Leng (from Dev Box)
649a1dda15 [PowerDisplay] Add implementation plan for VCP code resolution
TDD, bite-sized task plan covering the registry/resolver/map, persistence,
DDC controller wiring, app orchestration (push/persist/clear-on-refresh),
and the diagnostics surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:17:42 +08:00
Yu Leng (from Dev Box)
5487807d6f [PowerDisplay] Add design spec for per-feature VCP code resolution
Design for resolving each PowerDisplay feature to one of an ordered list
of candidate VCP codes per monitor (cap-string-first, probe-as-fallback),
persisting the decision (including a not-supported sentinel) until the
user refreshes, and surfacing the resolved code in diagnostics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:02:46 +08:00
66 changed files with 3083 additions and 5647 deletions

View File

@@ -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.

View 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 12 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.

File diff suppressed because it is too large Load Diff

View File

@@ -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());

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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)
{
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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();

View File

@@ -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>

View File

@@ -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="&#xE946;"
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>

View File

@@ -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);
}
}
}
}

View File

@@ -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="&#xE946;"
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>

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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()

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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"}

View File

@@ -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).");
}
}

View File

@@ -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));
}
}

View File

@@ -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 _));
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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>

View File

@@ -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,
}
}

View File

@@ -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&lt;string,int&gt;</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&lt;string,int&gt;</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;
}
}
}

View File

@@ -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
{
}

View File

@@ -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,
};
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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))]

View File

@@ -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);
}
}
}
}
}

View File

@@ -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))]

View File

@@ -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=&#xE8E9;}">
<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="&#xE72E;" />
</InfoBar.IconSource>
</InfoBar>
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonScripts_RuntimeCard">
<tkcontrols:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE943;" />
</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="&#xE8B7;" />
</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=&#xE8DA;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
<Button
x:Name="OpenScriptsFolderButton"
VerticalAlignment="Bottom"
Click="OpenScriptsFolder_Click"
Content="{ui:FontIcon Glyph=&#xE838;,
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="&#xE943;" />
</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=&#xE8DA;,
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="&#xEC7A;" />
</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=&#xE756;}"
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="&#xE8A7;"
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"

View File

@@ -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

View File

@@ -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\&lt;user&gt;\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>

View File

@@ -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;