mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-03 00:49:18 +02:00
Compare commits
3 Commits
copilot/up
...
shawn/Pyth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
879163f48e | ||
|
|
4b84c00300 | ||
|
|
6062bdc2f8 |
@@ -14,6 +14,7 @@ using AdvancedPaste.Helpers;
|
|||||||
using AdvancedPaste.Models;
|
using AdvancedPaste.Models;
|
||||||
using AdvancedPaste.Services;
|
using AdvancedPaste.Services;
|
||||||
using AdvancedPaste.Services.CustomActions;
|
using AdvancedPaste.Services.CustomActions;
|
||||||
|
using AdvancedPaste.Services.PythonScripts;
|
||||||
using AdvancedPaste.Settings;
|
using AdvancedPaste.Settings;
|
||||||
using AdvancedPaste.ViewModels;
|
using AdvancedPaste.ViewModels;
|
||||||
using ManagedCommon;
|
using ManagedCommon;
|
||||||
@@ -83,6 +84,8 @@ namespace AdvancedPaste
|
|||||||
services.AddSingleton<IPasteAIProviderFactory, PasteAIProviderFactory>();
|
services.AddSingleton<IPasteAIProviderFactory, PasteAIProviderFactory>();
|
||||||
services.AddSingleton<ICustomActionTransformService, CustomActionTransformService>();
|
services.AddSingleton<ICustomActionTransformService, CustomActionTransformService>();
|
||||||
services.AddSingleton<IKernelService, AdvancedAIKernelService>();
|
services.AddSingleton<IKernelService, AdvancedAIKernelService>();
|
||||||
|
services.AddSingleton<IPythonScriptService, PythonScriptService>();
|
||||||
|
services.AddSingleton<IPythonScriptTrustService, PythonScriptTrustService>();
|
||||||
services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>();
|
services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>();
|
||||||
services.AddSingleton<OptionsViewModel>();
|
services.AddSingleton<OptionsViewModel>();
|
||||||
}).Build();
|
}).Build();
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ namespace AdvancedPaste
|
|||||||
double GetHeight(int maxCustomActionCount) =>
|
double GetHeight(int maxCustomActionCount) =>
|
||||||
baseHeight +
|
baseHeight +
|
||||||
new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) +
|
new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) +
|
||||||
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0);
|
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0) +
|
||||||
|
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.PythonScriptPasteFormats.Count);
|
||||||
|
|
||||||
MinHeight = GetHeight(1);
|
MinHeight = GetHeight(1);
|
||||||
Height = GetHeight(5);
|
Height = GetHeight(5);
|
||||||
@@ -59,6 +60,7 @@ namespace AdvancedPaste
|
|||||||
UpdateHeight();
|
UpdateHeight();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
_optionsViewModel.PythonScriptPasteFormats.CollectionChanged += (_, _) => UpdateHeight();
|
||||||
|
|
||||||
AppWindow.SetIcon("Assets/AdvancedPaste/AdvancedPaste.ico");
|
AppWindow.SetIcon("Assets/AdvancedPaste/AdvancedPaste.ico");
|
||||||
this.ExtendsContentIntoTitleBar = true;
|
this.ExtendsContentIntoTitleBar = true;
|
||||||
|
|||||||
@@ -306,6 +306,8 @@
|
|||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
|
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.PythonScriptPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<ListView
|
<ListView
|
||||||
@@ -341,6 +343,27 @@
|
|||||||
ScrollViewer.VerticalScrollMode="Disabled"
|
ScrollViewer.VerticalScrollMode="Disabled"
|
||||||
SelectionMode="None"
|
SelectionMode="None"
|
||||||
TabIndex="2" />
|
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>
|
</Grid>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -27,8 +27,20 @@ namespace AdvancedPaste.Settings
|
|||||||
|
|
||||||
public PasteAIConfiguration PasteAIConfiguration { get; }
|
public PasteAIConfiguration PasteAIConfiguration { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions { get; }
|
||||||
|
|
||||||
|
public string PythonScriptsFolder { get; }
|
||||||
|
|
||||||
|
public string PythonExecutablePath { get; }
|
||||||
|
|
||||||
|
public int PythonScriptTimeoutSeconds { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; }
|
||||||
|
|
||||||
public event EventHandler Changed;
|
public event EventHandler Changed;
|
||||||
|
|
||||||
Task SetActiveAIProviderAsync(string providerId);
|
Task SetActiveAIProviderAsync(string providerId);
|
||||||
|
|
||||||
|
void StoreTrustedScriptHash(string scriptPath, string hash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.IO.Abstractions;
|
using System.IO.Abstractions;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -25,6 +26,10 @@ namespace AdvancedPaste.Settings
|
|||||||
private readonly Lock _loadingSettingsLock = new();
|
private readonly Lock _loadingSettingsLock = new();
|
||||||
private readonly List<PasteFormats> _additionalActions;
|
private readonly List<PasteFormats> _additionalActions;
|
||||||
private readonly List<AdvancedPasteCustomAction> _customActions;
|
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 string AdvancedPasteModuleName = "AdvancedPaste";
|
||||||
private const int MaxNumberOfRetry = 5;
|
private const int MaxNumberOfRetry = 5;
|
||||||
@@ -48,6 +53,16 @@ namespace AdvancedPaste.Settings
|
|||||||
|
|
||||||
public PasteAIConfiguration PasteAIConfiguration { get; private set; }
|
public PasteAIConfiguration PasteAIConfiguration { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => _pythonScriptActions;
|
||||||
|
|
||||||
|
public string PythonScriptsFolder { get; private set; }
|
||||||
|
|
||||||
|
public string PythonExecutablePath { get; private set; }
|
||||||
|
|
||||||
|
public int PythonScriptTimeoutSeconds { get; private set; } = 30;
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; private set; } = new Dictionary<string, string>();
|
||||||
|
|
||||||
public UserSettings(IFileSystem fileSystem)
|
public UserSettings(IFileSystem fileSystem)
|
||||||
{
|
{
|
||||||
_settingsUtils = new SettingsUtils(fileSystem);
|
_settingsUtils = new SettingsUtils(fileSystem);
|
||||||
@@ -57,8 +72,12 @@ namespace AdvancedPaste.Settings
|
|||||||
CloseAfterLosingFocus = false;
|
CloseAfterLosingFocus = false;
|
||||||
EnableClipboardPreview = true;
|
EnableClipboardPreview = true;
|
||||||
PasteAIConfiguration = new PasteAIConfiguration();
|
PasteAIConfiguration = new PasteAIConfiguration();
|
||||||
|
PythonScriptsFolder = GetDefaultScriptsFolder();
|
||||||
|
PythonExecutablePath = string.Empty;
|
||||||
|
PythonScriptTimeoutSeconds = 30;
|
||||||
_additionalActions = [];
|
_additionalActions = [];
|
||||||
_customActions = [];
|
_customActions = [];
|
||||||
|
_pythonScriptActions = [];
|
||||||
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
|
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
|
||||||
|
|
||||||
LoadSettingsFromJson();
|
LoadSettingsFromJson();
|
||||||
@@ -66,6 +85,14 @@ namespace AdvancedPaste.Settings
|
|||||||
_watcher = Helper.GetFileWatcher(AdvancedPasteModuleName, "settings.json", OnSettingsFileChanged, fileSystem);
|
_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()
|
private void OnSettingsFileChanged()
|
||||||
{
|
{
|
||||||
lock (_loadingSettingsLock)
|
lock (_loadingSettingsLock)
|
||||||
@@ -131,6 +158,21 @@ namespace AdvancedPaste.Settings
|
|||||||
_customActions.Clear();
|
_customActions.Clear();
|
||||||
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
|
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
|
||||||
|
|
||||||
|
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
|
||||||
|
PythonScriptsFolder = string.IsNullOrWhiteSpace(pythonScripts.ScriptsFolder)
|
||||||
|
? GetDefaultScriptsFolder()
|
||||||
|
: pythonScripts.ScriptsFolder;
|
||||||
|
PythonExecutablePath = pythonScripts.PythonExecutablePath ?? 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.Where(a => a.IsShown));
|
||||||
|
|
||||||
|
UpdateScriptFolderWatcher(PythonScriptsFolder);
|
||||||
|
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +337,102 @@ namespace AdvancedPaste.Settings
|
|||||||
return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant();
|
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 = new CancellationTokenSource();
|
||||||
|
|
||||||
|
Task.Delay(TimeSpan.FromMilliseconds(500))
|
||||||
|
.ContinueWith(
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
Task.Factory
|
||||||
|
.StartNew(
|
||||||
|
() => Changed?.Invoke(this, EventArgs.Empty),
|
||||||
|
CancellationToken.None,
|
||||||
|
TaskCreationOptions.None,
|
||||||
|
_taskScheduler)
|
||||||
|
.Wait();
|
||||||
|
},
|
||||||
|
_scriptFolderDebounce.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)
|
public async Task SetActiveAIProviderAsync(string providerId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(providerId))
|
if (string.IsNullOrWhiteSpace(providerId))
|
||||||
@@ -387,6 +525,8 @@ namespace AdvancedPaste.Settings
|
|||||||
if (disposing)
|
if (disposing)
|
||||||
{
|
{
|
||||||
_cancellationTokenSource?.Dispose();
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_scriptFolderDebounce?.Dispose();
|
||||||
|
_scriptFolderWatcher?.Dispose();
|
||||||
_watcher?.Dispose();
|
_watcher?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ public sealed class PasteFormat
|
|||||||
IsSavedQuery = isSavedQuery,
|
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 PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
|
||||||
|
|
||||||
public string IconGlyph => Metadata.IconGlyph;
|
public string IconGlyph => Metadata.IconGlyph;
|
||||||
|
|||||||
@@ -122,4 +122,13 @@ 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.",
|
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)]
|
RequiresPrompt = true)]
|
||||||
CustomTextTransformation,
|
CustomTextTransformation,
|
||||||
|
|
||||||
|
[PasteFormatMetadata(
|
||||||
|
IsCoreAction = false,
|
||||||
|
IconGlyph = "\uE943",
|
||||||
|
RequiresAIService = false,
|
||||||
|
CanPreview = true,
|
||||||
|
SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Image | ClipboardFormat.Audio | ClipboardFormat.Video | ClipboardFormat.File,
|
||||||
|
KernelFunctionDescription = "Runs a user-provided Python script on clipboard content.")]
|
||||||
|
PythonScript,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) Microsoft Corporation
|
// Copyright (c) Microsoft Corporation
|
||||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
@@ -9,15 +9,23 @@ using System.Threading.Tasks;
|
|||||||
using AdvancedPaste.Helpers;
|
using AdvancedPaste.Helpers;
|
||||||
using AdvancedPaste.Models;
|
using AdvancedPaste.Models;
|
||||||
using AdvancedPaste.Services.CustomActions;
|
using AdvancedPaste.Services.CustomActions;
|
||||||
|
using AdvancedPaste.Services.PythonScripts;
|
||||||
|
using ManagedCommon;
|
||||||
using Microsoft.PowerToys.Telemetry;
|
using Microsoft.PowerToys.Telemetry;
|
||||||
using Windows.ApplicationModel.DataTransfer;
|
using Windows.ApplicationModel.DataTransfer;
|
||||||
|
|
||||||
namespace AdvancedPaste.Services;
|
namespace AdvancedPaste.Services;
|
||||||
|
|
||||||
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor
|
public sealed class PasteFormatExecutor(
|
||||||
|
IKernelService kernelService,
|
||||||
|
ICustomActionTransformService customActionTransformService,
|
||||||
|
IPythonScriptService pythonScriptService,
|
||||||
|
IPythonScriptTrustService pythonScriptTrustService) : IPasteFormatExecutor
|
||||||
{
|
{
|
||||||
private readonly IKernelService _kernelService = kernelService;
|
private readonly IKernelService _kernelService = kernelService;
|
||||||
private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService;
|
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)
|
public async Task<DataPackage> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress<double> progress)
|
||||||
{
|
{
|
||||||
@@ -32,6 +40,15 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
|
|||||||
|
|
||||||
var clipboardData = Clipboard.GetContent();
|
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.
|
// 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 () =>
|
return await Task.Run(async () =>
|
||||||
pasteFormat.Format switch
|
pasteFormat.Format switch
|
||||||
@@ -42,6 +59,85 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
var hash = _pythonScriptTrustService.ComputeHash(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);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
else if (view.Contains(StandardDataFormats.Html))
|
||||||
|
{
|
||||||
|
pkg.SetHtmlFormat(await view.GetHtmlFormatAsync());
|
||||||
|
}
|
||||||
|
else if (view.Contains(StandardDataFormats.StorageItems))
|
||||||
|
{
|
||||||
|
var items = await view.GetStorageItemsAsync();
|
||||||
|
pkg.SetStorageItems(items);
|
||||||
|
}
|
||||||
|
else if (view.Contains(StandardDataFormats.Bitmap))
|
||||||
|
{
|
||||||
|
var bitmap = await view.GetBitmapAsync();
|
||||||
|
pkg.SetBitmap(bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
|
||||||
private static void WriteTelemetry(PasteFormats format, PasteActionSource source)
|
private static void WriteTelemetry(PasteFormats format, PasteActionSource source)
|
||||||
{
|
{
|
||||||
switch (source)
|
switch (source)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using 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>
|
||||||
|
/// Windows mode: the script directly manipulates the clipboard. C# waits for the process to exit.
|
||||||
|
/// </summary>
|
||||||
|
Task ExecuteWindowsScriptAsync(string scriptPath, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WSL mode: C# passes data via JSON stdin, receives a DataPackage from JSON stdout.
|
||||||
|
/// </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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// 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,
|
||||||
|
IReadOnlyList<PythonRequirement> Requirements);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
|||||||
|
// 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),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -372,4 +372,60 @@
|
|||||||
<value>Unable to load Foundry Local model: {0}</value>
|
<value>Unable to load Foundry Local model: {0}</value>
|
||||||
<comment>{0} is the model identifier. Do not translate {0}.</comment>
|
<comment>{0} is the model identifier. Do not translate {0}.</comment>
|
||||||
</data>
|
</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}</value>
|
||||||
|
<comment>{0} is the script file path. Do not translate {0}.</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>
|
||||||
</root>
|
</root>
|
||||||
@@ -16,6 +16,7 @@ using System.Threading.Tasks;
|
|||||||
using AdvancedPaste.Helpers;
|
using AdvancedPaste.Helpers;
|
||||||
using AdvancedPaste.Models;
|
using AdvancedPaste.Models;
|
||||||
using AdvancedPaste.Services;
|
using AdvancedPaste.Services;
|
||||||
|
using AdvancedPaste.Services.PythonScripts;
|
||||||
using AdvancedPaste.Settings;
|
using AdvancedPaste.Settings;
|
||||||
using Common.UI;
|
using Common.UI;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
@@ -41,6 +42,7 @@ namespace AdvancedPaste.ViewModels
|
|||||||
private readonly IUserSettings _userSettings;
|
private readonly IUserSettings _userSettings;
|
||||||
private readonly IPasteFormatExecutor _pasteFormatExecutor;
|
private readonly IPasteFormatExecutor _pasteFormatExecutor;
|
||||||
private readonly IAICredentialsProvider _credentialsProvider;
|
private readonly IAICredentialsProvider _credentialsProvider;
|
||||||
|
private readonly IPythonScriptService _pythonScriptService;
|
||||||
|
|
||||||
private CancellationTokenSource _pasteActionCancellationTokenSource;
|
private CancellationTokenSource _pasteActionCancellationTokenSource;
|
||||||
|
|
||||||
@@ -100,6 +102,8 @@ namespace AdvancedPaste.ViewModels
|
|||||||
|
|
||||||
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
|
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
|
||||||
|
|
||||||
|
public ObservableCollection<PasteFormat> PythonScriptPasteFormats { get; } = [];
|
||||||
|
|
||||||
public bool IsCustomAIServiceEnabled
|
public bool IsCustomAIServiceEnabled
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -258,11 +262,12 @@ namespace AdvancedPaste.ViewModels
|
|||||||
|
|
||||||
public event EventHandler PreviewRequested;
|
public event EventHandler PreviewRequested;
|
||||||
|
|
||||||
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
|
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor, IPythonScriptService pythonScriptService)
|
||||||
{
|
{
|
||||||
_credentialsProvider = credentialsProvider;
|
_credentialsProvider = credentialsProvider;
|
||||||
_userSettings = userSettings;
|
_userSettings = userSettings;
|
||||||
_pasteFormatExecutor = pasteFormatExecutor;
|
_pasteFormatExecutor = pasteFormatExecutor;
|
||||||
|
_pythonScriptService = pythonScriptService;
|
||||||
|
|
||||||
GeneratedResponses = [];
|
GeneratedResponses = [];
|
||||||
GeneratedResponses.CollectionChanged += (s, e) =>
|
GeneratedResponses.CollectionChanged += (s, e) =>
|
||||||
@@ -413,12 +418,46 @@ namespace AdvancedPaste.ViewModels
|
|||||||
}
|
}
|
||||||
|
|
||||||
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
|
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
|
||||||
.Where(format => PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format))
|
.Where(format => format != PasteFormats.PythonScript &&
|
||||||
|
(PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format)))
|
||||||
.Select(CreateStandardPasteFormat));
|
.Select(CreateStandardPasteFormat));
|
||||||
|
|
||||||
UpdateFormats(
|
UpdateFormats(
|
||||||
CustomActionPasteFormats,
|
CustomActionPasteFormats,
|
||||||
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []);
|
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []);
|
||||||
|
|
||||||
|
UpdateFormats(
|
||||||
|
PythonScriptPasteFormats,
|
||||||
|
BuildPythonScriptFormats());
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<PasteFormat> BuildPythonScriptFormats()
|
||||||
|
{
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
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()
|
public void Dispose()
|
||||||
@@ -692,7 +731,10 @@ namespace AdvancedPaste.ViewModels
|
|||||||
_pasteActionCancellationTokenSource = new();
|
_pasteActionCancellationTokenSource = new();
|
||||||
TransformProgress = double.NaN;
|
TransformProgress = double.NaN;
|
||||||
PasteActionError = PasteActionError.None;
|
PasteActionError = PasteActionError.None;
|
||||||
Query = pasteFormat.Query;
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -732,7 +774,7 @@ namespace AdvancedPaste.ViewModels
|
|||||||
|
|
||||||
internal async Task ExecutePasteFormatAsync(VirtualKey key)
|
internal async Task ExecutePasteFormatAsync(VirtualKey key)
|
||||||
{
|
{
|
||||||
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)
|
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats).Concat(PythonScriptPasteFormats)
|
||||||
.Where(pasteFormat => pasteFormat.IsEnabled)
|
.Where(pasteFormat => pasteFormat.IsEnabled)
|
||||||
.ElementAtOrDefault(key - VirtualKey.Number1);
|
.ElementAtOrDefault(key - VirtualKey.Number1);
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
|||||||
[CmdConfigureIgnoreAttribute]
|
[CmdConfigureIgnoreAttribute]
|
||||||
public PasteAIConfiguration PasteAIConfiguration { get; set; }
|
public PasteAIConfiguration PasteAIConfiguration { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("python-scripts")]
|
||||||
|
[CmdConfigureIgnoreAttribute]
|
||||||
|
public AdvancedPastePythonScriptSettings PythonScripts { get; set; } = new();
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
=> JsonSerializer.Serialize(this, SettingsSerializationContext.Default.AdvancedPasteProperties);
|
=> JsonSerializer.Serialize(this, SettingsSerializationContext.Default.AdvancedPasteProperties);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 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.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 HotkeySettings _shortcut = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("scriptPath")]
|
||||||
|
public string ScriptPath
|
||||||
|
{
|
||||||
|
get => _scriptPath;
|
||||||
|
set => Set(ref _scriptPath, value ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get => _name;
|
||||||
|
set => Set(ref _name, value ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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("shortcut")]
|
||||||
|
public HotkeySettings Shortcut
|
||||||
|
{
|
||||||
|
get => _shortcut;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_shortcut != value)
|
||||||
|
{
|
||||||
|
_shortcut = value ?? new();
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public IEnumerable<IAdvancedPasteAction> SubActions => [];
|
||||||
|
|
||||||
|
public object Clone()
|
||||||
|
{
|
||||||
|
return new AdvancedPastePythonScriptAction
|
||||||
|
{
|
||||||
|
ScriptPath = ScriptPath,
|
||||||
|
Name = Name,
|
||||||
|
Description = Description,
|
||||||
|
IsShown = IsShown,
|
||||||
|
Shortcut = Shortcut,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||||
|
|
||||||
|
public sealed class AdvancedPastePythonScriptSettings
|
||||||
|
{
|
||||||
|
[JsonPropertyName("scriptsFolder")]
|
||||||
|
public string ScriptsFolder { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("pythonExecutablePath")]
|
||||||
|
public string PythonExecutablePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[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; } = [];
|
||||||
|
}
|
||||||
@@ -138,6 +138,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
|||||||
[JsonSerializable(typeof(AdvancedPasteAdditionalAction))]
|
[JsonSerializable(typeof(AdvancedPasteAdditionalAction))]
|
||||||
[JsonSerializable(typeof(AdvancedPastePasteAsFileAction))]
|
[JsonSerializable(typeof(AdvancedPastePasteAsFileAction))]
|
||||||
[JsonSerializable(typeof(AdvancedPasteTranscodeAction))]
|
[JsonSerializable(typeof(AdvancedPasteTranscodeAction))]
|
||||||
|
[JsonSerializable(typeof(AdvancedPastePythonScriptAction))]
|
||||||
|
[JsonSerializable(typeof(AdvancedPastePythonScriptSettings))]
|
||||||
|
[JsonSerializable(typeof(System.Collections.Generic.List<AdvancedPastePythonScriptAction>))]
|
||||||
|
[JsonSerializable(typeof(System.Collections.Generic.Dictionary<string, string>))]
|
||||||
[JsonSerializable(typeof(ImageResizerSizes))]
|
[JsonSerializable(typeof(ImageResizerSizes))]
|
||||||
[JsonSerializable(typeof(ImageResizerCustomSizeProperty))]
|
[JsonSerializable(typeof(ImageResizerCustomSizeProperty))]
|
||||||
[JsonSerializable(typeof(KeyboardKeysProperty))]
|
[JsonSerializable(typeof(KeyboardKeysProperty))]
|
||||||
|
|||||||
@@ -390,6 +390,28 @@
|
|||||||
IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}"
|
IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}"
|
||||||
Severity="Warning" />
|
Severity="Warning" />
|
||||||
</controls:SettingsGroup>
|
</controls:SettingsGroup>
|
||||||
|
|
||||||
|
<controls:SettingsGroup x:Uid="AdvancedPaste_PythonScripts_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||||
|
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonExecutablePath_SettingsCard">
|
||||||
|
<tkcontrols:SettingsCard.HeaderIcon>
|
||||||
|
<FontIcon Glyph="" />
|
||||||
|
</tkcontrols:SettingsCard.HeaderIcon>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBox
|
||||||
|
x:Name="PythonExecutablePathTextBox"
|
||||||
|
x:Uid="AdvancedPaste_PythonExecutablePath_TextBox"
|
||||||
|
MinWidth="300"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
Text="{x:Bind ViewModel.PythonExecutablePath, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
|
||||||
|
<Button
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Click="BrowsePythonExecutablePath_Click"
|
||||||
|
Content="{ui:FontIcon Glyph=,
|
||||||
|
FontSize=16}"
|
||||||
|
Style="{StaticResource SubtleButtonStyle}" />
|
||||||
|
</StackPanel>
|
||||||
|
</tkcontrols:SettingsCard>
|
||||||
|
</controls:SettingsGroup>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</controls:SettingsPageControl.ModuleContent>
|
</controls:SettingsPageControl.ModuleContent>
|
||||||
<controls:SettingsPageControl.PrimaryLinks>
|
<controls:SettingsPageControl.PrimaryLinks>
|
||||||
|
|||||||
@@ -264,6 +264,22 @@ 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 static string PickFileDialog(string filter, string title, string initialDir = null, int initialFilter = 0)
|
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
|
// Use Win32 OpenFileName dialog as FileOpenPicker doesn't work with elevated permissions
|
||||||
|
|||||||
@@ -598,6 +598,18 @@ opera.exe</value>
|
|||||||
<data name="AdvancedPaste_Additional_Actions_GroupSettings.Header" xml:space="preserve">
|
<data name="AdvancedPaste_Additional_Actions_GroupSettings.Header" xml:space="preserve">
|
||||||
<value>Custom actions</value>
|
<value>Custom actions</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="AdvancedPaste_PythonScripts_GroupSettings.Header" xml:space="preserve">
|
||||||
|
<value>Python scripts</value>
|
||||||
|
</data>
|
||||||
|
<data name="AdvancedPaste_PythonExecutablePath_SettingsCard.Header" xml:space="preserve">
|
||||||
|
<value>Python interpreter</value>
|
||||||
|
</data>
|
||||||
|
<data name="AdvancedPaste_PythonExecutablePath_SettingsCard.Description" xml:space="preserve">
|
||||||
|
<value>Path to the Python executable used to run scripts. Leave blank to detect automatically (supports Anaconda, Miniconda, system Python).</value>
|
||||||
|
</data>
|
||||||
|
<data name="AdvancedPaste_PythonExecutablePath_TextBox.PlaceholderText" xml:space="preserve">
|
||||||
|
<value>Auto-detect (e.g. C:\Users\<user>\anaconda3\python.exe)</value>
|
||||||
|
</data>
|
||||||
<data name="AdvancedPaste_FoundryLocal_LegalDescription" xml:space="preserve">
|
<data name="AdvancedPaste_FoundryLocal_LegalDescription" xml:space="preserve">
|
||||||
<value>You're running local models directly on your device. Their behavior may vary or be unpredictable.</value>
|
<value>You're running local models directly on your device. Their behavior may vary or be unpredictable.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -293,6 +293,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
|||||||
|
|
||||||
public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions;
|
public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions;
|
||||||
|
|
||||||
|
public string PythonExecutablePath
|
||||||
|
{
|
||||||
|
get => _advancedPasteSettings.Properties.PythonScripts?.PythonExecutablePath ?? string.Empty;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||||
|
if (!string.Equals(scripts.PythonExecutablePath, value, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
scripts.PythonExecutablePath = value ?? string.Empty;
|
||||||
|
OnPropertyChanged(nameof(PythonExecutablePath));
|
||||||
|
SaveAndNotifySettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static IEnumerable<AIServiceTypeMetadata> AvailableProviders => AIServiceTypeRegistry.GetAvailableServiceTypes();
|
public static IEnumerable<AIServiceTypeMetadata> AvailableProviders => AIServiceTypeRegistry.GetAvailableServiceTypes();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user