chore: first version
@@ -0,0 +1,48 @@
|
|||||||
|
# Command Palette Extension – Copilot Instructions
|
||||||
|
|
||||||
|
Concise guidance for AI-assisted development of this Command Palette extension.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
| Folder | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `Pages/` | Extension pages (ListPage, ContentPage, DynamicListPage implementations) |
|
||||||
|
| `Assets/` | Icons and images (StoreLogo.png, etc.) |
|
||||||
|
| `Properties/` | Launch settings and publish profiles |
|
||||||
|
| Root `.cs` files | Extension entry point, COM server (Program.cs), and CommandsProvider |
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- Extensions run **out-of-process** via COM server registration
|
||||||
|
- `Program.cs` hosts the COM server — do not modify the hosting pattern
|
||||||
|
- The `CommandProvider` subclass is the entry point for all commands
|
||||||
|
- Pages are **ICommand** implementations — they can be used anywhere commands are used
|
||||||
|
- Always **Deploy** (not just Build) to register the MSIX package
|
||||||
|
- After deploying, use the **Reload** command in Command Palette to refresh
|
||||||
|
|
||||||
|
## Build & Deploy
|
||||||
|
|
||||||
|
1. In Visual Studio, use **Build > Deploy** (not just Build)
|
||||||
|
2. In Command Palette, run `Reload` → select "Reload Command Palette extensions"
|
||||||
|
3. For debugging, run in Debug configuration (F5) and check Output window (Ctrl+Alt+O)
|
||||||
|
|
||||||
|
## Source Control
|
||||||
|
|
||||||
|
If using git, remove these lines from `.gitignore` (needed for deployment):
|
||||||
|
- `**/Properties/launchSettings.json`
|
||||||
|
- `*.pubxml`
|
||||||
|
|
||||||
|
## Available Skills
|
||||||
|
|
||||||
|
This project includes Copilot skills for common workflows:
|
||||||
|
- **add-adaptive-card-form** — Create form-based UI with Adaptive Cards
|
||||||
|
- **add-extension-settings** — Add a settings page to your extension
|
||||||
|
- **add-dock-band** — Add persistent toolbar widgets
|
||||||
|
- **add-fallback-commands** — Add catch-all search commands
|
||||||
|
- **publish-extension** — Publish to Microsoft Store or WinGet
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Creating an extension](https://learn.microsoft.com/windows/powertoys/command-palette/creating-an-extension)
|
||||||
|
- [Extension samples](https://learn.microsoft.com/windows/powertoys/command-palette/samples)
|
||||||
|
- [Extensibility overview](https://learn.microsoft.com/windows/powertoys/command-palette/extensibility-overview)
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
---
|
||||||
|
description: 'Comprehensive guide for developing Command Palette extensions — covers pages, content, commands, items, icons, settings, dock, and debugging'
|
||||||
|
applyTo: '**/*.cs'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Command Palette Extension Development
|
||||||
|
|
||||||
|
Complete reference for building Command Palette (CmdPal) extensions. Extensions run out-of-process as MSIX-packaged COM servers.
|
||||||
|
|
||||||
|
## Extension Architecture
|
||||||
|
|
||||||
|
### IExtension Interface
|
||||||
|
|
||||||
|
The root class implements `IExtension` and `IDisposable`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Guid("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")]
|
||||||
|
public sealed partial class MyExtension : IExtension, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ManualResetEvent _extensionDisposedEvent;
|
||||||
|
private readonly MyCommandsProvider _provider = new();
|
||||||
|
|
||||||
|
public MyExtension(ManualResetEvent extensionDisposedEvent)
|
||||||
|
{
|
||||||
|
_extensionDisposedEvent = extensionDisposedEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? GetProvider(ProviderType providerType) => providerType switch
|
||||||
|
{
|
||||||
|
ProviderType.Commands => _provider,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public void Dispose() => _extensionDisposedEvent.Set();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Only `ProviderType.Commands` is currently supported
|
||||||
|
- The `[Guid]` must match the CLSID in `Package.appxmanifest`
|
||||||
|
|
||||||
|
### CommandProvider
|
||||||
|
|
||||||
|
Override `TopLevelCommands()` to register main commands. Optionally override `FallbackCommands()` and `GetDockBands()`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public partial class MyCommandsProvider : CommandProvider
|
||||||
|
{
|
||||||
|
public MyCommandsProvider()
|
||||||
|
{
|
||||||
|
DisplayName = "My Extension";
|
||||||
|
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ICommandItem[] TopLevelCommands() => [
|
||||||
|
new CommandItem(new MyPage()) { Title = DisplayName },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### COM Server (Program.cs)
|
||||||
|
|
||||||
|
`Program.cs` hosts the COM server. Do not change this pattern:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class Program
|
||||||
|
{
|
||||||
|
[MTAThread]
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer")
|
||||||
|
{
|
||||||
|
global::Shmuelie.WinRTServer.ComServer server = new();
|
||||||
|
ManualResetEvent extensionDisposedEvent = new(false);
|
||||||
|
var extensionInstance = new MyExtension(extensionDisposedEvent);
|
||||||
|
server.RegisterClass<MyExtension, IExtension>(() => extensionInstance);
|
||||||
|
server.Start();
|
||||||
|
extensionDisposedEvent.WaitOne();
|
||||||
|
server.Stop();
|
||||||
|
server.UnsafeDispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package.appxmanifest
|
||||||
|
|
||||||
|
Two critical extension registrations must be present:
|
||||||
|
|
||||||
|
1. **COM server** — `com:ComServer` with matching CLSID and `-RegisterProcessAsComServer` args
|
||||||
|
2. **App extension** — `uap3:AppExtension` with `Name="com.microsoft.commandpalette"` and `CreateInstance ClassId` matching the GUID
|
||||||
|
|
||||||
|
The CLSID must be identical in three places: the `[Guid]` attribute, the `com:Class Id`, and the `CreateInstance ClassId`.
|
||||||
|
|
||||||
|
## Page Types
|
||||||
|
|
||||||
|
### ListPage (Most Common)
|
||||||
|
|
||||||
|
Displays a searchable list of items:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class MyPage : ListPage
|
||||||
|
{
|
||||||
|
public MyPage()
|
||||||
|
{
|
||||||
|
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||||
|
Title = "My page";
|
||||||
|
Name = "Open";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IListItem[] GetItems() => [
|
||||||
|
new ListItem(new OpenUrlCommand("https://example.com")) { Title = "Example" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DynamicListPage (Search-Reactive)
|
||||||
|
|
||||||
|
Responds to search text changes for filtering or live queries:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class MyDynamicPage : DynamicListPage
|
||||||
|
{
|
||||||
|
private IListItem[] _filteredItems = [];
|
||||||
|
|
||||||
|
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||||
|
{
|
||||||
|
_filteredItems = _allItems
|
||||||
|
.Where(i => i.Title.Contains(newSearch, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
RaiseItemsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IListItem[] GetItems() => _filteredItems;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Supports `Filters` property for category filtering
|
||||||
|
- Call `RaiseItemsChanged()` after updating items to notify the UI
|
||||||
|
|
||||||
|
### ContentPage (Rich Content)
|
||||||
|
|
||||||
|
Displays rich content like markdown, forms, or images:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class MyContentPage : ContentPage
|
||||||
|
{
|
||||||
|
public override IContent[] GetContent() => [
|
||||||
|
new MarkdownContent("# Hello\nThis is **markdown**."),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Can return multiple `IContent` items (mix markdown, forms, images, etc.)
|
||||||
|
- Supports `Commands` property for context menu items via `CommandContextItem`
|
||||||
|
|
||||||
|
## Content Types
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `MarkdownContent(string)` | Renders markdown with headers, links, code blocks, tables, images |
|
||||||
|
| `FormContent` | Adaptive Cards forms with `TemplateJson`, optional `DataJson`, and `SubmitForm()` |
|
||||||
|
| `PlainTextContent(string)` | Plain text; optional `FontFamily.Monospace` and `WrapWords` |
|
||||||
|
| `ImageContent` | Images with `MaxWidth`/`MaxHeight` constraints |
|
||||||
|
| `TreeContent` | Hierarchical nested content; override `GetChildren()` for child `IContent[]` |
|
||||||
|
|
||||||
|
### MarkdownContent Images
|
||||||
|
|
||||||
|
Supports `file:`, `data:` (base64), and `https:` URLs. Image hints control rendering:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
### FormContent (Adaptive Cards)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class MyForm : FormContent
|
||||||
|
{
|
||||||
|
public MyForm()
|
||||||
|
{
|
||||||
|
TemplateJson = """{ "type": "AdaptiveCard", ... }""";
|
||||||
|
DataJson = """{ "name": "default" }""";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override CommandResult SubmitForm(string payload)
|
||||||
|
{
|
||||||
|
var data = JsonSerializer.Deserialize<MyFormData>(payload);
|
||||||
|
return CommandResult.Dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Design cards visually at [adaptivecards.io/designer](https://adaptivecards.io/designer)
|
||||||
|
- Use `${...}` placeholders in `TemplateJson` bound to `DataJson` properties
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### InvokableCommand
|
||||||
|
|
||||||
|
Actions that do something when activated:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class MyCommand : InvokableCommand
|
||||||
|
{
|
||||||
|
public override string Name => "Do it";
|
||||||
|
public override IconInfo Icon => new("\uE945");
|
||||||
|
|
||||||
|
public override CommandResult Invoke()
|
||||||
|
{
|
||||||
|
// Do work here
|
||||||
|
return CommandResult.Dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Built-in Command Helpers
|
||||||
|
|
||||||
|
| Helper | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `OpenUrlCommand(string url)` | Open URL in default browser |
|
||||||
|
| `CopyTextCommand(string text)` | Copy to clipboard with toast |
|
||||||
|
| `NoOpCommand()` | Does nothing (placeholder) |
|
||||||
|
| `AnonymousCommand(Action? action)` | Lambda command; set `Result` property for navigation |
|
||||||
|
|
||||||
|
### CommandResult Types
|
||||||
|
|
||||||
|
| Result | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `CommandResult.Dismiss()` | Hide palette, go home |
|
||||||
|
| `CommandResult.KeepOpen()` | Stay on current page |
|
||||||
|
| `CommandResult.Hide()` | Hide palette, keep page state |
|
||||||
|
| `CommandResult.GoBack()` | Navigate back one page |
|
||||||
|
| `CommandResult.GoHome()` | Navigate to home page |
|
||||||
|
| `CommandResult.ShowToast("msg")` | Show toast notification, then dismiss |
|
||||||
|
| `CommandResult.Confirm(args)` | Show confirmation dialog before proceeding |
|
||||||
|
|
||||||
|
## ListItem Properties
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new ListItem(command)
|
||||||
|
{
|
||||||
|
Title = "Display name",
|
||||||
|
Subtitle = "Secondary text",
|
||||||
|
Icon = new IconInfo("\uE8A7"),
|
||||||
|
Tags = [new Tag("label") { Foreground = ColorHelpers.FromRgb(255, 0, 0) }],
|
||||||
|
Details = new Details
|
||||||
|
{
|
||||||
|
Title = "Detail panel",
|
||||||
|
Body = "**Markdown** body",
|
||||||
|
HeroImage = IconHelpers.FromRelativePath("Assets\\hero.png"),
|
||||||
|
Size = ContentSize.Medium,
|
||||||
|
Metadata = [
|
||||||
|
new DetailsLink("URL", "https://example.com"),
|
||||||
|
new DetailsSeparator(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
MoreCommands = [
|
||||||
|
new CommandContextItem(deleteCommand)
|
||||||
|
{
|
||||||
|
RequestedShortcut = KeyChordHelpers.FromModifiers(
|
||||||
|
true, false, false, (int)VirtualKey.Delete),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sections and Grid Layouts
|
||||||
|
|
||||||
|
### Sections
|
||||||
|
|
||||||
|
Group items under section headers:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override ISection[] GetSections() => [
|
||||||
|
new Section { Title = "Group A", Items = itemsA },
|
||||||
|
new Section { Title = "Group B", Items = itemsB },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid Layouts
|
||||||
|
|
||||||
|
Set `GridProperties` on a `ListPage`:
|
||||||
|
|
||||||
|
| Layout | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `GalleryGridLayout()` | Large tiles with title + subtitle |
|
||||||
|
| `SmallGridLayout()` | Compact grid |
|
||||||
|
| `MediumGridLayout()` | Medium tiles with title |
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Segoe Fluent UI icons (most common)
|
||||||
|
new IconInfo("\uE8A5") // Document
|
||||||
|
new IconInfo("\uE945") // Lightning bolt
|
||||||
|
|
||||||
|
// Emoji
|
||||||
|
new IconInfo("📂")
|
||||||
|
|
||||||
|
// Image from package assets
|
||||||
|
IconHelpers.FromRelativePath("Assets\\StoreLogo.png")
|
||||||
|
|
||||||
|
// Remote URL or SVG
|
||||||
|
new IconInfo("https://example.com/icon.svg")
|
||||||
|
|
||||||
|
// From exe/dll resource
|
||||||
|
new IconInfo("%systemroot%\\system32\\shell32.dll,3")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Updates
|
||||||
|
|
||||||
|
- Call `RaiseItemsChanged()` on any page to trigger a UI refresh of its items
|
||||||
|
- Call `RaisePropertyChanged(propertyName)` for individual property updates (e.g., title)
|
||||||
|
- For top-level command changes, call `RaiseItemsChanged()` on the `CommandProvider`
|
||||||
|
- Use `System.Timers.Timer` for periodic background updates
|
||||||
|
|
||||||
|
## Status Messages and Toasts
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Inline status message (e.g., loading indicator)
|
||||||
|
var msg = new StatusMessage
|
||||||
|
{
|
||||||
|
Message = "Loading...",
|
||||||
|
State = MessageState.Info,
|
||||||
|
Progress = new ProgressState { IsIndeterminate = true },
|
||||||
|
};
|
||||||
|
ExtensionHost.ShowStatus(msg, StatusContext.Page);
|
||||||
|
ExtensionHost.HideStatus(msg);
|
||||||
|
|
||||||
|
// Transient toast notification
|
||||||
|
new ToastStatusMessage("Copied to clipboard").Show();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & Debug
|
||||||
|
|
||||||
|
1. Select **Debug** configuration
|
||||||
|
2. **Deploy** via Build > Deploy (not just Build) — this registers the MSIX package
|
||||||
|
3. Press **F5** to launch with debugger attached
|
||||||
|
4. Use `Debug.Write()` / `Debug.WriteLine()` for diagnostic output
|
||||||
|
5. Check Output window (**Ctrl+Alt+O**) set to "Debug"
|
||||||
|
6. In Command Palette, run `Reload` → "Reload Command Palette extensions"
|
||||||
|
|
||||||
|
Use the `(Package)` launch profile, not `(Unpackaged)`.
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Building without deploying | Use Build > Deploy so the MSIX package is updated |
|
||||||
|
| Running "(Unpackaged)" profile | Select the "(Package)" launch profile |
|
||||||
|
| Forgetting to reload extensions | Run `Reload` in Command Palette after deploying |
|
||||||
|
| CLSID mismatch | Ensure `[Guid]` in .cs matches `ClassId` in Package.appxmanifest (both places) |
|
||||||
|
| Logging in hot paths | `GetItems()` is called frequently — avoid expensive work or logging here |
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
name: add-adaptive-card-form
|
||||||
|
description: >-
|
||||||
|
Create form-based UI for your Command Palette extension using Adaptive Cards.
|
||||||
|
Use when asked to add forms, user input fields, toggle switches, text inputs,
|
||||||
|
dropdown menus, data entry, surveys, configuration dialogs, or interactive
|
||||||
|
content pages. Supports the Adaptive Cards Designer for visual form building.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Forms with Adaptive Cards
|
||||||
|
|
||||||
|
Create interactive forms in your Command Palette extension using Adaptive Cards. Forms allow you to collect user input through text fields, toggles, dropdowns, and other controls.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Adding a form to collect user input (name, settings, feedback)
|
||||||
|
- Creating interactive configuration dialogs
|
||||||
|
- Building data entry interfaces
|
||||||
|
- Adding toggle switches or dropdown menus
|
||||||
|
- Displaying complex layouts beyond simple lists
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Familiarity with [Adaptive Cards](https://adaptivecards.io/)
|
||||||
|
- Optional: Use the [Adaptive Card Designer](https://adaptivecards.io/designer/) to visually build your form
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Step 1: Create a ContentPage with FormContent
|
||||||
|
|
||||||
|
Create a new file in your `Pages/` directory:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace YourExtension;
|
||||||
|
|
||||||
|
internal sealed partial class MyFormPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly MyForm _form = new();
|
||||||
|
|
||||||
|
public MyFormPage()
|
||||||
|
{
|
||||||
|
Name = "Open";
|
||||||
|
Title = "My Form";
|
||||||
|
Icon = new IconInfo("\uECA5");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IContent[] GetContent() => [_form];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed partial class MyForm : FormContent
|
||||||
|
{
|
||||||
|
public MyForm()
|
||||||
|
{
|
||||||
|
TemplateJson = """
|
||||||
|
{
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.6",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "Input.Text",
|
||||||
|
"label": "Name",
|
||||||
|
"id": "Name",
|
||||||
|
"isRequired": true,
|
||||||
|
"errorMessage": "Name is required",
|
||||||
|
"placeholder": "Enter your name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Action.Submit",
|
||||||
|
"title": "Submit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override CommandResult SubmitForm(string payload)
|
||||||
|
{
|
||||||
|
var formInput = JsonNode.Parse(payload)?.AsObject();
|
||||||
|
if (formInput == null)
|
||||||
|
{
|
||||||
|
return CommandResult.GoHome();
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = formInput["Name"]?.ToString() ?? "Unknown";
|
||||||
|
return CommandResult.ShowToast($"Hello, {name}!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Register the Page
|
||||||
|
|
||||||
|
In your `CommandsProvider`, add the form page:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_commands = [
|
||||||
|
new CommandItem(new MyFormPage()) { Title = "My Form" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Deploy and Test
|
||||||
|
|
||||||
|
1. Deploy your extension
|
||||||
|
2. In Command Palette, run `Reload`
|
||||||
|
3. Navigate to your form and submit it
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### TemplateJson
|
||||||
|
The JSON layout of your form (from Adaptive Cards schema). Design it at https://adaptivecards.io/designer/
|
||||||
|
|
||||||
|
### DataJson (Optional)
|
||||||
|
Dynamic data binding using `${...}` placeholders in your TemplateJson:
|
||||||
|
```csharp
|
||||||
|
TemplateJson = """{ "body": [{ "type": "TextBlock", "text": "${title}" }] }""";
|
||||||
|
DataJson = """{ "title": "Dynamic Title" }""";
|
||||||
|
```
|
||||||
|
|
||||||
|
### SubmitForm
|
||||||
|
Called when the user submits. Parse `payload` as JSON to read input values by their `id`.
|
||||||
|
|
||||||
|
### Mixing Content Types
|
||||||
|
You can combine forms with markdown on the same page:
|
||||||
|
```csharp
|
||||||
|
public override IContent[] GetContent() => [
|
||||||
|
new MarkdownContent("# Instructions\nFill out the form below."),
|
||||||
|
_form,
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Form Patterns
|
||||||
|
|
||||||
|
See [form-patterns.md](references/form-patterns.md) for template JSON for common form types.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Get user input with forms](https://learn.microsoft.com/windows/powertoys/command-palette/using-form-pages)
|
||||||
|
- [Adaptive Card Designer](https://adaptivecards.io/designer/)
|
||||||
|
- [Adaptive Cards Schema](https://adaptivecards.io/explorer/)
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
# Common Adaptive Card Form Patterns
|
||||||
|
|
||||||
|
Reusable template JSON and handler code for the most common form types in Command Palette extensions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Simple Text Input Form
|
||||||
|
|
||||||
|
A basic form with one or two text fields and a submit button.
|
||||||
|
|
||||||
|
### TemplateJson
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.6",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "Input.Text",
|
||||||
|
"id": "FirstName",
|
||||||
|
"label": "First Name",
|
||||||
|
"placeholder": "Enter your first name",
|
||||||
|
"isRequired": true,
|
||||||
|
"errorMessage": "First name is required"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.Text",
|
||||||
|
"id": "Email",
|
||||||
|
"label": "Email Address",
|
||||||
|
"placeholder": "user@example.com",
|
||||||
|
"style": "Email",
|
||||||
|
"isRequired": true,
|
||||||
|
"errorMessage": "A valid email is required"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Action.Submit",
|
||||||
|
"title": "Submit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SubmitForm Handler
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override CommandResult SubmitForm(string payload)
|
||||||
|
{
|
||||||
|
var input = JsonNode.Parse(payload)?.AsObject();
|
||||||
|
if (input == null) return CommandResult.GoHome();
|
||||||
|
|
||||||
|
var firstName = input["FirstName"]?.ToString() ?? "";
|
||||||
|
var email = input["Email"]?.ToString() ?? "";
|
||||||
|
|
||||||
|
return CommandResult.ShowToast($"Registered {firstName} ({email})");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toggle/Checkbox Form
|
||||||
|
|
||||||
|
Use `Input.Toggle` for boolean on/off settings. Combine with `DataJson` for dynamic defaults.
|
||||||
|
|
||||||
|
### TemplateJson
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.6",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": "Preferences",
|
||||||
|
"weight": "Bolder",
|
||||||
|
"size": "Medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.Toggle",
|
||||||
|
"id": "AcceptsTerms",
|
||||||
|
"title": "I accept the terms and conditions",
|
||||||
|
"valueOn": "true",
|
||||||
|
"valueOff": "false",
|
||||||
|
"value": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.Toggle",
|
||||||
|
"id": "EnableNotifications",
|
||||||
|
"title": "Enable notifications",
|
||||||
|
"valueOn": "true",
|
||||||
|
"valueOff": "false",
|
||||||
|
"value": "${notificationsDefault}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.Toggle",
|
||||||
|
"id": "DarkMode",
|
||||||
|
"title": "Use dark mode",
|
||||||
|
"valueOn": "true",
|
||||||
|
"valueOff": "false",
|
||||||
|
"value": "${darkModeDefault}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Action.Submit",
|
||||||
|
"title": "Save Preferences"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataJson (Dynamic Defaults)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
DataJson = """
|
||||||
|
{
|
||||||
|
"notificationsDefault": "true",
|
||||||
|
"darkModeDefault": "false"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
```
|
||||||
|
|
||||||
|
### SubmitForm Handler
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override CommandResult SubmitForm(string payload)
|
||||||
|
{
|
||||||
|
var input = JsonNode.Parse(payload)?.AsObject();
|
||||||
|
if (input == null) return CommandResult.GoHome();
|
||||||
|
|
||||||
|
var accepted = input["AcceptsTerms"]?.ToString() == "true";
|
||||||
|
var notifications = input["EnableNotifications"]?.ToString() == "true";
|
||||||
|
var darkMode = input["DarkMode"]?.ToString() == "true";
|
||||||
|
|
||||||
|
if (!accepted)
|
||||||
|
{
|
||||||
|
return CommandResult.ShowToast("You must accept the terms to continue.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save preferences...
|
||||||
|
return CommandResult.ShowToast("Preferences saved!");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choice Set (Dropdown/Radio) Form
|
||||||
|
|
||||||
|
Use `Input.ChoiceSet` for single-select dropdowns or radio buttons.
|
||||||
|
|
||||||
|
### Compact Style (Dropdown)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.6",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "Input.ChoiceSet",
|
||||||
|
"id": "Priority",
|
||||||
|
"label": "Priority Level",
|
||||||
|
"style": "compact",
|
||||||
|
"value": "medium",
|
||||||
|
"choices": [
|
||||||
|
{ "title": "Low", "value": "low" },
|
||||||
|
{ "title": "Medium", "value": "medium" },
|
||||||
|
{ "title": "High", "value": "high" },
|
||||||
|
{ "title": "Critical", "value": "critical" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.ChoiceSet",
|
||||||
|
"id": "Category",
|
||||||
|
"label": "Category",
|
||||||
|
"style": "compact",
|
||||||
|
"choices": [
|
||||||
|
{ "title": "Bug Report", "value": "bug" },
|
||||||
|
{ "title": "Feature Request", "value": "feature" },
|
||||||
|
{ "title": "Documentation", "value": "docs" },
|
||||||
|
{ "title": "Question", "value": "question" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Action.Submit",
|
||||||
|
"title": "Create Issue"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expanded Style (Radio Buttons)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.6",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "Input.ChoiceSet",
|
||||||
|
"id": "Theme",
|
||||||
|
"label": "Select a theme",
|
||||||
|
"style": "expanded",
|
||||||
|
"value": "system",
|
||||||
|
"choices": [
|
||||||
|
{ "title": "Light", "value": "light" },
|
||||||
|
{ "title": "Dark", "value": "dark" },
|
||||||
|
{ "title": "System Default", "value": "system" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Action.Submit",
|
||||||
|
"title": "Apply"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Section Form
|
||||||
|
|
||||||
|
Combine multiple input types with TextBlock headers to create organized, multi-section forms. Use `Action.ShowCard` for progressive disclosure of optional sections.
|
||||||
|
|
||||||
|
### TemplateJson
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.6",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": "Personal Information",
|
||||||
|
"weight": "Bolder",
|
||||||
|
"size": "Medium",
|
||||||
|
"separator": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.Text",
|
||||||
|
"id": "FullName",
|
||||||
|
"label": "Full Name",
|
||||||
|
"placeholder": "Enter your full name",
|
||||||
|
"isRequired": true,
|
||||||
|
"errorMessage": "Name is required"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.Text",
|
||||||
|
"id": "Email",
|
||||||
|
"label": "Email",
|
||||||
|
"placeholder": "user@example.com",
|
||||||
|
"style": "Email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": "Preferences",
|
||||||
|
"weight": "Bolder",
|
||||||
|
"size": "Medium",
|
||||||
|
"separator": true,
|
||||||
|
"spacing": "Large"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.ChoiceSet",
|
||||||
|
"id": "Language",
|
||||||
|
"label": "Preferred Language",
|
||||||
|
"style": "compact",
|
||||||
|
"value": "en",
|
||||||
|
"choices": [
|
||||||
|
{ "title": "English", "value": "en" },
|
||||||
|
{ "title": "Spanish", "value": "es" },
|
||||||
|
{ "title": "French", "value": "fr" },
|
||||||
|
{ "title": "German", "value": "de" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.Toggle",
|
||||||
|
"id": "Newsletter",
|
||||||
|
"title": "Subscribe to newsletter",
|
||||||
|
"valueOn": "true",
|
||||||
|
"valueOff": "false",
|
||||||
|
"value": "true"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Action.Submit",
|
||||||
|
"title": "Save Profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Action.ShowCard",
|
||||||
|
"title": "Advanced Options",
|
||||||
|
"card": {
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "Input.Text",
|
||||||
|
"id": "ApiKey",
|
||||||
|
"label": "API Key (optional)",
|
||||||
|
"placeholder": "Enter your API key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.Toggle",
|
||||||
|
"id": "DebugMode",
|
||||||
|
"title": "Enable debug mode",
|
||||||
|
"valueOn": "true",
|
||||||
|
"valueOff": "false",
|
||||||
|
"value": "false"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Action.Submit",
|
||||||
|
"title": "Save All"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback Form
|
||||||
|
|
||||||
|
A common pattern for collecting user feedback with a multiline text area and a rating.
|
||||||
|
|
||||||
|
### TemplateJson
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.6",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": "We'd love your feedback!",
|
||||||
|
"weight": "Bolder",
|
||||||
|
"size": "Medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": "Tell us what you think and how we can improve.",
|
||||||
|
"wrap": true,
|
||||||
|
"spacing": "Small"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.ChoiceSet",
|
||||||
|
"id": "Rating",
|
||||||
|
"label": "How would you rate your experience?",
|
||||||
|
"style": "expanded",
|
||||||
|
"isRequired": true,
|
||||||
|
"errorMessage": "Please select a rating",
|
||||||
|
"choices": [
|
||||||
|
{ "title": "⭐ Poor", "value": "1" },
|
||||||
|
{ "title": "⭐⭐ Fair", "value": "2" },
|
||||||
|
{ "title": "⭐⭐⭐ Good", "value": "3" },
|
||||||
|
{ "title": "⭐⭐⭐⭐ Great", "value": "4" },
|
||||||
|
{ "title": "⭐⭐⭐⭐⭐ Excellent", "value": "5" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Input.Text",
|
||||||
|
"id": "Comments",
|
||||||
|
"label": "Comments",
|
||||||
|
"placeholder": "Share your thoughts...",
|
||||||
|
"isMultiline": true,
|
||||||
|
"maxLength": 500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Action.Submit",
|
||||||
|
"title": "Send Feedback"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SubmitForm Handler with Confirmation Dialog
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override CommandResult SubmitForm(string payload)
|
||||||
|
{
|
||||||
|
var input = JsonNode.Parse(payload)?.AsObject();
|
||||||
|
if (input == null) return CommandResult.GoHome();
|
||||||
|
|
||||||
|
var rating = input["Rating"]?.ToString() ?? "0";
|
||||||
|
var comments = input["Comments"]?.ToString() ?? "";
|
||||||
|
|
||||||
|
return CommandResult.Confirm(new ConfirmationArgs
|
||||||
|
{
|
||||||
|
Title = "Submit feedback?",
|
||||||
|
Description = $"Rating: {rating}/5\n\n{(string.IsNullOrEmpty(comments) ? "No comments" : comments)}",
|
||||||
|
PrimaryCommand = new AnonymousCommand(() =>
|
||||||
|
{
|
||||||
|
// Process and store feedback
|
||||||
|
new ToastStatusMessage("Thank you for your feedback!").Show();
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Name = "Submit",
|
||||||
|
Result = CommandResult.Dismiss(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tree Content with Forms (Comment/Reply Pattern)
|
||||||
|
|
||||||
|
Use `TreeContent` to create nested, threaded discussions where each node can contain a form for replies.
|
||||||
|
|
||||||
|
### Post Content (Tree Node)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class PostContent : TreeContent
|
||||||
|
{
|
||||||
|
private readonly string _author;
|
||||||
|
private readonly string _body;
|
||||||
|
private readonly PostReplyForm _replyForm;
|
||||||
|
private readonly List<PostContent> _childPosts = [];
|
||||||
|
|
||||||
|
public PostContent(string author, string body)
|
||||||
|
{
|
||||||
|
_author = author;
|
||||||
|
_body = body;
|
||||||
|
_replyForm = new PostReplyForm(this);
|
||||||
|
|
||||||
|
TemplateJson = """
|
||||||
|
{
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.6",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": "${author}",
|
||||||
|
"weight": "Bolder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "TextBlock",
|
||||||
|
"text": "${body}",
|
||||||
|
"wrap": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
DataJson = $$"""{ "author": "{{_author}}", "body": "{{_body}}" }""";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IContent[] GetChildren() => [_replyForm, .. _childPosts];
|
||||||
|
|
||||||
|
public void AddReply(PostContent reply) => _childPosts.Add(reply);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reply Form (Child of Tree Node)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class PostReplyForm : FormContent
|
||||||
|
{
|
||||||
|
private readonly PostContent _parent;
|
||||||
|
|
||||||
|
public PostReplyForm(PostContent parent)
|
||||||
|
{
|
||||||
|
_parent = parent;
|
||||||
|
TemplateJson = """
|
||||||
|
{
|
||||||
|
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
"type": "AdaptiveCard",
|
||||||
|
"version": "1.6",
|
||||||
|
"body": [
|
||||||
|
{
|
||||||
|
"type": "Input.Text",
|
||||||
|
"id": "ReplyText",
|
||||||
|
"placeholder": "Write a reply...",
|
||||||
|
"isMultiline": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "Action.Submit",
|
||||||
|
"title": "Reply"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override CommandResult SubmitForm(string payload)
|
||||||
|
{
|
||||||
|
var input = JsonNode.Parse(payload)?.AsObject();
|
||||||
|
if (input == null) return CommandResult.GoHome();
|
||||||
|
|
||||||
|
var replyText = input["ReplyText"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(replyText))
|
||||||
|
{
|
||||||
|
_parent.AddReply(new PostContent("You", replyText));
|
||||||
|
}
|
||||||
|
|
||||||
|
return CommandResult.KeepOpen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hosting the Thread on a ContentPage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class ThreadPage : ContentPage
|
||||||
|
{
|
||||||
|
private readonly PostContent _rootPost;
|
||||||
|
|
||||||
|
public ThreadPage()
|
||||||
|
{
|
||||||
|
Name = "Discussion";
|
||||||
|
Title = "Discussion Thread";
|
||||||
|
Icon = new IconInfo("\uE90A");
|
||||||
|
|
||||||
|
_rootPost = new PostContent("Alice", "Has anyone tried the new API?");
|
||||||
|
_rootPost.AddReply(new PostContent("Bob", "Yes! It works great."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IContent[] GetContent() => [_rootPost];
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
name: add-dock-band
|
||||||
|
description: >-
|
||||||
|
Add dock band support to your Command Palette extension for persistent toolbar widgets.
|
||||||
|
Use when asked to add dock support, toolbar buttons, persistent UI widgets,
|
||||||
|
taskbar integration, live-updating status displays, quick-access buttons,
|
||||||
|
or always-visible controls. Supports single buttons, multi-button strips,
|
||||||
|
and live-updating content.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Dock Band Support
|
||||||
|
|
||||||
|
The Command Palette Dock is a persistent toolbar at the edge of the user's screen. Your extension can provide **dock bands** — strips of items that appear in the Dock — giving users quick access to commands without opening the full Command Palette.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Adding a quick-access button to the persistent toolbar
|
||||||
|
- Creating a multi-button toolbar strip
|
||||||
|
- Displaying live-updating information (clock, CPU usage, etc.)
|
||||||
|
- Providing frequently-used commands without opening the full palette
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Command Palette Extension SDK version 0.9 or later (`Microsoft.CommandPalette.Extensions` ≥ 0.9.260303001)
|
||||||
|
|
||||||
|
## Quick Start: Single Button Dock Band
|
||||||
|
|
||||||
|
Override `GetDockBands()` in your `CommandProvider`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public partial class MyCommandsProvider : CommandProvider
|
||||||
|
{
|
||||||
|
private readonly ICommandItem[] _commands;
|
||||||
|
private readonly ICommandItem _dockBand;
|
||||||
|
|
||||||
|
public MyCommandsProvider()
|
||||||
|
{
|
||||||
|
DisplayName = "My Extension";
|
||||||
|
Id = "com.mycompany.myextension"; // Unique ID required for dock
|
||||||
|
|
||||||
|
var mainPage = new MyPage();
|
||||||
|
_dockBand = new CommandItem(mainPage) { Title = DisplayName };
|
||||||
|
_commands = [new CommandItem(mainPage) { Title = DisplayName }];
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ICommandItem[] TopLevelCommands() => _commands;
|
||||||
|
|
||||||
|
public override ICommandItem[]? GetDockBands() => [_dockBand];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Button Dock Band
|
||||||
|
|
||||||
|
Use `WrappedDockItem` to create a band with multiple buttons:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override ICommandItem[]? GetDockBands()
|
||||||
|
{
|
||||||
|
var button1 = new ListItem(new OpenUrlCommand("https://github.com"))
|
||||||
|
{
|
||||||
|
Title = "GitHub",
|
||||||
|
Icon = new IconInfo("\uE774"),
|
||||||
|
};
|
||||||
|
var button2 = new ListItem(new OpenUrlCommand("https://learn.microsoft.com"))
|
||||||
|
{
|
||||||
|
Title = "Learn",
|
||||||
|
Icon = new IconInfo("\uE82D"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var band = new WrappedDockItem(
|
||||||
|
[button1, button2],
|
||||||
|
"com.mycompany.myextension.quicklinks", // Unique band ID
|
||||||
|
"Quick Links");
|
||||||
|
|
||||||
|
return [band];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Live-Updating Dock Band
|
||||||
|
|
||||||
|
Create a dock band that updates its content periodically (like a clock):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class LiveStatusBand : ListItem
|
||||||
|
{
|
||||||
|
private readonly System.Timers.Timer _timer;
|
||||||
|
|
||||||
|
public LiveStatusBand()
|
||||||
|
: base(new NoOpCommand() { Result = CommandResult.KeepOpen() })
|
||||||
|
{
|
||||||
|
Title = DateTime.Now.ToString("HH:mm");
|
||||||
|
Icon = new IconInfo("\uE823"); // Clock icon
|
||||||
|
|
||||||
|
_timer = new System.Timers.Timer(60_000); // Update every minute
|
||||||
|
_timer.Elapsed += (s, e) =>
|
||||||
|
{
|
||||||
|
Title = DateTime.Now.ToString("HH:mm");
|
||||||
|
Subtitle = DateTime.Now.ToString("dddd, MMMM d");
|
||||||
|
};
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In CommandProvider:
|
||||||
|
public override ICommandItem[]? GetDockBands()
|
||||||
|
{
|
||||||
|
var band = new WrappedDockItem(
|
||||||
|
[new LiveStatusBand()],
|
||||||
|
"com.mycompany.myextension.status",
|
||||||
|
"Live Status");
|
||||||
|
return [band];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How Dock Bands Render
|
||||||
|
|
||||||
|
| Command Type on ICommandItem | Dock Behavior |
|
||||||
|
|------------------------------|---------------|
|
||||||
|
| `IInvokableCommand` | Single button that executes the command |
|
||||||
|
| `IListPage` | Each list item renders as a separate button in one band |
|
||||||
|
| `IContentPage` | Single expandable button with a flyout |
|
||||||
|
|
||||||
|
## Support Pinning Nested Commands
|
||||||
|
|
||||||
|
By default, only top-level commands and dock bands can be pinned. To allow pinning nested commands:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override ICommandItem? GetCommandItem(string id)
|
||||||
|
{
|
||||||
|
// Look up commands by their Id
|
||||||
|
foreach (var item in GetAllCommands())
|
||||||
|
{
|
||||||
|
if (item?.Command is ICommand cmd && cmd.Id == id)
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- All dock band `ICommandItem` objects must have a `Command` with a **non-empty `Id`** — items without an ID are ignored
|
||||||
|
- Set `Id` on your `CommandProvider` (e.g., `Id = "com.mycompany.myextension"`)
|
||||||
|
- Use `WrappedDockItem` for multi-button bands backed by a `ListPage`
|
||||||
|
- Keep dock band updates lightweight — they run frequently
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Adding Dock support](https://learn.microsoft.com/windows/powertoys/command-palette/adding-dock-support)
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
name: add-extension-settings
|
||||||
|
description: >-
|
||||||
|
Add a settings page to your Command Palette extension.
|
||||||
|
Use when asked to add settings, preferences, configuration options,
|
||||||
|
toggles, text inputs, dropdowns, or user-customizable behavior.
|
||||||
|
Covers ToggleSetting, TextSetting, ChoiceSetSetting, and persistence.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Extension Settings
|
||||||
|
|
||||||
|
Add a settings page to your Command Palette extension using the built-in settings helpers. Settings are automatically persisted and restored by the extension host.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Adding user-configurable options to your extension
|
||||||
|
- Creating toggle switches for features
|
||||||
|
- Adding text input fields for configuration
|
||||||
|
- Creating dropdown menus for option selection
|
||||||
|
- Persisting user preferences across sessions
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Step 1: Create a Settings Manager
|
||||||
|
|
||||||
|
Create a new file `SettingsManager.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
|
||||||
|
namespace YourExtension;
|
||||||
|
|
||||||
|
internal sealed class SettingsManager
|
||||||
|
{
|
||||||
|
private readonly Settings _settings;
|
||||||
|
|
||||||
|
public SettingsManager()
|
||||||
|
{
|
||||||
|
_settings = new Settings();
|
||||||
|
|
||||||
|
var maxResults = new TextSetting(
|
||||||
|
"maxResults",
|
||||||
|
"Maximum Results",
|
||||||
|
"Maximum number of results to display",
|
||||||
|
"10");
|
||||||
|
|
||||||
|
var showSubtitles = new ToggleSetting(
|
||||||
|
"showSubtitles",
|
||||||
|
"Show Subtitles",
|
||||||
|
"Display subtitle text under each result",
|
||||||
|
true);
|
||||||
|
|
||||||
|
var sortOrder = new ChoiceSetSetting(
|
||||||
|
"sortOrder",
|
||||||
|
"Sort Order",
|
||||||
|
"How to sort results",
|
||||||
|
[
|
||||||
|
new ChoiceSetSetting.Choice("Alphabetical", "alpha"),
|
||||||
|
new ChoiceSetSetting.Choice("Most Recent", "recent"),
|
||||||
|
new ChoiceSetSetting.Choice("Most Used", "frequent"),
|
||||||
|
],
|
||||||
|
"alpha");
|
||||||
|
|
||||||
|
_settings.AddSetting(maxResults);
|
||||||
|
_settings.AddSetting(showSubtitles);
|
||||||
|
_settings.AddSetting(sortOrder);
|
||||||
|
|
||||||
|
// React to settings changes
|
||||||
|
_settings.SettingsChanged += OnSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommandSettings Settings => _settings;
|
||||||
|
|
||||||
|
public int MaxResults => int.TryParse(
|
||||||
|
_settings.GetSetting<string>("maxResults"), out var val) ? val : 10;
|
||||||
|
|
||||||
|
public bool ShowSubtitles =>
|
||||||
|
_settings.GetSetting<bool>("showSubtitles");
|
||||||
|
|
||||||
|
public string SortOrder =>
|
||||||
|
_settings.GetSetting<string>("sortOrder") ?? "alpha";
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// React to settings changes (e.g., refresh data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Wire into CommandProvider
|
||||||
|
|
||||||
|
In your `CommandsProvider`, expose the settings:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public partial class MyCommandsProvider : CommandProvider
|
||||||
|
{
|
||||||
|
private readonly SettingsManager _settingsManager = new();
|
||||||
|
private readonly ICommandItem[] _commands;
|
||||||
|
|
||||||
|
public MyCommandsProvider()
|
||||||
|
{
|
||||||
|
DisplayName = "My Extension";
|
||||||
|
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||||
|
Settings = _settingsManager.Settings; // This exposes settings to CmdPal
|
||||||
|
_commands = [
|
||||||
|
new CommandItem(new MyPage(_settingsManager)) { Title = DisplayName },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ICommandItem[] TopLevelCommands() => _commands;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Use Settings in Pages
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class MyPage : ListPage
|
||||||
|
{
|
||||||
|
private readonly SettingsManager _settings;
|
||||||
|
|
||||||
|
public MyPage(SettingsManager settings)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IListItem[] GetItems()
|
||||||
|
{
|
||||||
|
var items = GetAllItems();
|
||||||
|
return items.Take(_settings.MaxResults).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setting Types
|
||||||
|
|
||||||
|
| Type | UI Control | Value Type | Constructor Parameters |
|
||||||
|
|------|-----------|------------|----------------------|
|
||||||
|
| `ToggleSetting` | Toggle switch | `bool` | `(id, label, description, defaultValue)` |
|
||||||
|
| `TextSetting` | Text input | `string` | `(id, label, description, defaultValue)` |
|
||||||
|
| `ChoiceSetSetting` | Dropdown | `string` | `(id, label, description, choices[], defaultValue)` |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- Settings are automatically persisted by the CmdPal host
|
||||||
|
- Use `SettingsChanged` event to react to changes in real-time
|
||||||
|
- Access values via `GetSetting<T>(id)` with the setting's string id
|
||||||
|
- Pass the settings manager to pages/commands that need configuration
|
||||||
|
- Settings page appears automatically when `Settings` is set on `CommandProvider`
|
||||||
|
|
||||||
|
## Grouping Settings
|
||||||
|
|
||||||
|
For extensions with many settings, organize them into logical groups:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public SettingsManager()
|
||||||
|
{
|
||||||
|
_settings = new Settings();
|
||||||
|
|
||||||
|
// Appearance group
|
||||||
|
var theme = new ChoiceSetSetting("theme", "Theme", "UI theme",
|
||||||
|
[
|
||||||
|
new ChoiceSetSetting.Choice("Light", "light"),
|
||||||
|
new ChoiceSetSetting.Choice("Dark", "dark"),
|
||||||
|
new ChoiceSetSetting.Choice("System", "system"),
|
||||||
|
],
|
||||||
|
"system");
|
||||||
|
|
||||||
|
var fontSize = new TextSetting("fontSize", "Font Size", "Display font size", "14");
|
||||||
|
|
||||||
|
// Behavior group
|
||||||
|
var autoRefresh = new ToggleSetting("autoRefresh", "Auto-Refresh",
|
||||||
|
"Automatically refresh results", true);
|
||||||
|
|
||||||
|
var refreshInterval = new TextSetting("refreshInterval", "Refresh Interval",
|
||||||
|
"Seconds between auto-refreshes", "30");
|
||||||
|
|
||||||
|
_settings.AddSetting(theme);
|
||||||
|
_settings.AddSetting(fontSize);
|
||||||
|
_settings.AddSetting(autoRefresh);
|
||||||
|
_settings.AddSetting(refreshInterval);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reacting to Changes
|
||||||
|
|
||||||
|
Use the `SettingsChanged` event to update behavior when the user modifies settings:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void OnSettingsChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// Invalidate cached data
|
||||||
|
_cachedItems = null;
|
||||||
|
|
||||||
|
// Notify pages to refresh
|
||||||
|
OnItemsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [SampleSettingsPage.cs](https://github.com/microsoft/PowerToys/blob/main/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleSettingsPage.cs)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
name: add-fallback-commands
|
||||||
|
description: >-
|
||||||
|
Add fallback commands to your Command Palette extension for catch-all search behavior.
|
||||||
|
Use when asked to add search functionality, query matching, direct input handling,
|
||||||
|
calculator-style evaluation, URL opening, command execution, or results that appear
|
||||||
|
when no other extension matches. Used by 14 of 20 built-in extensions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Fallback Commands
|
||||||
|
|
||||||
|
Fallback commands are shown in Command Palette when no other results match the user's query. They enable your extension to act as a catch-all handler — perfect for calculators, web search, command execution, file path opening, and more.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Adding search functionality that responds to any user input
|
||||||
|
- Creating a calculator that evaluates expressions as the user types
|
||||||
|
- Building a web search that triggers on unmatched queries
|
||||||
|
- Opening files or URLs typed directly into the palette
|
||||||
|
- Executing shell commands from the search bar
|
||||||
|
|
||||||
|
## How Fallback Commands Work
|
||||||
|
|
||||||
|
1. User types a query in Command Palette
|
||||||
|
2. If no top-level commands match, CmdPal asks extensions for fallback results
|
||||||
|
3. Your extension's `FallbackCommands()` provides items that respond to the query
|
||||||
|
4. The fallback items can be static (always shown) or dynamic (filtered by query)
|
||||||
|
|
||||||
|
## Quick Start: Static Fallback
|
||||||
|
|
||||||
|
Override `FallbackCommands()` in your `CommandProvider`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public partial class MyCommandsProvider : CommandProvider
|
||||||
|
{
|
||||||
|
private readonly ICommandItem[] _commands;
|
||||||
|
private readonly FallbackCommandItem[] _fallbacks;
|
||||||
|
|
||||||
|
public MyCommandsProvider()
|
||||||
|
{
|
||||||
|
DisplayName = "Web Search";
|
||||||
|
Icon = new IconInfo("\uE721"); // Search icon
|
||||||
|
|
||||||
|
var searchPage = new WebSearchPage();
|
||||||
|
_commands = [new CommandItem(searchPage) { Title = DisplayName }];
|
||||||
|
_fallbacks = [new FallbackCommandItem(searchPage) { Title = "Search the web" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ICommandItem[] TopLevelCommands() => _commands;
|
||||||
|
public override IFallbackCommandItem[] FallbackCommands() => _fallbacks;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Fallback with DynamicListPage
|
||||||
|
|
||||||
|
For fallbacks that filter results based on the query, use `DynamicListPage`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class WebSearchPage : DynamicListPage
|
||||||
|
{
|
||||||
|
private string _query = string.Empty;
|
||||||
|
|
||||||
|
public WebSearchPage()
|
||||||
|
{
|
||||||
|
Icon = new IconInfo("\uE721");
|
||||||
|
Title = "Web Search";
|
||||||
|
Name = "Search";
|
||||||
|
PlaceholderText = "Type to search...";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||||
|
{
|
||||||
|
_query = newSearch;
|
||||||
|
RaiseItemsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IListItem[] GetItems()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_query))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
new ListItem(new OpenUrlCommand($"https://www.google.com/search?q={Uri.EscapeDataString(_query)}"))
|
||||||
|
{
|
||||||
|
Title = $"Search Google for \"{_query}\"",
|
||||||
|
Icon = new IconInfo("\uE721"),
|
||||||
|
},
|
||||||
|
new ListItem(new OpenUrlCommand($"https://www.bing.com/search?q={Uri.EscapeDataString(_query)}"))
|
||||||
|
{
|
||||||
|
Title = $"Search Bing for \"{_query}\"",
|
||||||
|
Icon = new IconInfo("\uE721"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Fallback with Cancellation
|
||||||
|
|
||||||
|
For expensive operations (API calls, file searches), use cancellation to stay responsive:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed partial class SmartSearchPage : DynamicListPage
|
||||||
|
{
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private IListItem[] _results = [];
|
||||||
|
|
||||||
|
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||||
|
{
|
||||||
|
// Cancel any in-flight search
|
||||||
|
_cts?.Cancel();
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var token = _cts.Token;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
// Debounce: wait for user to stop typing
|
||||||
|
await Task.Delay(300, token);
|
||||||
|
if (token.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
// Perform search
|
||||||
|
_results = await SearchAsync(newSearch, token);
|
||||||
|
RaiseItemsChanged();
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IListItem[] GetItems() => _results;
|
||||||
|
|
||||||
|
private async Task<IListItem[]> SearchAsync(string query, CancellationToken token)
|
||||||
|
{
|
||||||
|
// Your search logic here
|
||||||
|
// Check token.IsCancellationRequested periodically
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Real-World Examples (from built-in extensions)
|
||||||
|
|
||||||
|
| Extension | Fallback Behavior |
|
||||||
|
|-----------|------------------|
|
||||||
|
| **Apps** | Search installed applications by name |
|
||||||
|
| **Calc** | Evaluate mathematical expressions directly |
|
||||||
|
| **Shell** | Execute command-line commands |
|
||||||
|
| **WebSearch** | Search the web with configured engine |
|
||||||
|
| **Indexer** | Open files by path |
|
||||||
|
| **TimeDate** | Parse time/date queries |
|
||||||
|
| **WindowsSettings** | Jump to Windows Settings pages |
|
||||||
|
| **WinGet** | Search WinGet packages |
|
||||||
|
| **WindowWalker** | Find and switch to open windows |
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
- `FallbackCommands()` returns `IFallbackCommandItem[]` (not `ICommandItem[]`)
|
||||||
|
- Use `FallbackCommandItem` wrapper (not `CommandItem`)
|
||||||
|
- Wrap a `DynamicListPage` for query-reactive results
|
||||||
|
- Cancel previous searches when new input arrives
|
||||||
|
- Keep fallback responses fast — users expect instant results
|
||||||
|
- Use `PlaceholderText` on your page to guide users
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Extension samples](https://learn.microsoft.com/windows/powertoys/command-palette/samples)
|
||||||
|
- [Extensibility overview](https://learn.microsoft.com/windows/powertoys/command-palette/extensibility-overview)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: publish-extension
|
||||||
|
description: >-
|
||||||
|
Publish your Command Palette extension to the Microsoft Store or WinGet.
|
||||||
|
Use when asked to publish, distribute, release, deploy to store,
|
||||||
|
create MSIX packages, submit to WinGet, set up CI/CD for releases,
|
||||||
|
or automate builds with GitHub Actions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Publish Your Command Palette Extension
|
||||||
|
|
||||||
|
Guide for distributing your Command Palette extension through the Microsoft Store, WinGet, or both.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Publishing your extension to the Microsoft Store
|
||||||
|
- Submitting your extension to WinGet for `winget install` discovery
|
||||||
|
- Setting up GitHub Actions to automate builds and releases
|
||||||
|
- Creating MSIX packages for Store submission
|
||||||
|
- Creating EXE installers for WinGet submission
|
||||||
|
|
||||||
|
## Publishing Options
|
||||||
|
|
||||||
|
| Channel | Package Format | Discovery | Auto-Updates |
|
||||||
|
|---------|---------------|-----------|--------------|
|
||||||
|
| Microsoft Store | MSIX bundle | Store app, `ms-windows-store://` link | Yes |
|
||||||
|
| WinGet | EXE installer | `winget install`, CmdPal browse | Yes (via manifest) |
|
||||||
|
|
||||||
|
**Recommendation**: Publish to both for maximum reach. WinGet enables direct discovery from within Command Palette.
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
### Microsoft Store Publishing
|
||||||
|
See [store-publishing.md](references/store-publishing.md) for the complete step-by-step guide.
|
||||||
|
|
||||||
|
**Summary:**
|
||||||
|
1. Register for Partner Center
|
||||||
|
2. Update `Package.appxmanifest` and `.csproj` with Partner Center identity
|
||||||
|
3. Build MSIX for x64 and ARM64
|
||||||
|
4. Create MSIX bundle
|
||||||
|
5. Submit to Partner Center
|
||||||
|
|
||||||
|
### WinGet Publishing
|
||||||
|
See [winget-publishing.md](references/winget-publishing.md) for the complete step-by-step guide.
|
||||||
|
|
||||||
|
**Summary:**
|
||||||
|
1. Switch project to unpackaged mode
|
||||||
|
2. Create Inno Setup installer script
|
||||||
|
3. Build EXE installers
|
||||||
|
4. Submit manifest via `wingetcreate new`
|
||||||
|
5. Optionally automate with GitHub Actions
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Visual Studio](https://visualstudio.microsoft.com/) with C# and WinUI workloads
|
||||||
|
- [Partner Center account](https://partner.microsoft.com/dashboard/home) (for Store publishing)
|
||||||
|
- [GitHub CLI](https://cli.github.com/) (for WinGet publishing)
|
||||||
|
- [WingetCreate](https://github.com/microsoft/winget-create) — `winget install Microsoft.WingetCreate`
|
||||||
|
- [Inno Setup](https://jrsoftware.org/isdl.php) (for WinGet EXE packaging)
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- Your extension's CLSID (the `[Guid("...")]` in your main .cs file) must be unique and consistent across all files
|
||||||
|
- WinGet manifests must include the `windows-commandpalette-extension` tag for CmdPal discovery
|
||||||
|
- MSIX packages require both x64 and ARM64 builds for Store submission
|
||||||
|
- WindowsAppSdk must be listed as a dependency in WinGet manifests
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Microsoft Store Publishing Guide
|
||||||
|
|
||||||
|
Complete step-by-step guide for publishing your Command Palette extension to the Microsoft Store.
|
||||||
|
|
||||||
|
## Step 1: Set Up Microsoft Store
|
||||||
|
|
||||||
|
1. Go to [Partner Center](https://partner.microsoft.com/dashboard/home)
|
||||||
|
2. Navigate to **Apps and Games** → **New product** → **MSIX or PWA app**
|
||||||
|
3. Reserve your app name (e.g., `My Extension for Command Palette`)
|
||||||
|
4. Once created, go to **Product Management** → **Product Identity**
|
||||||
|
5. Copy these three values — you'll need them in the next step:
|
||||||
|
|
||||||
|
| Partner Center Field | Where It Goes |
|
||||||
|
|---------------------|---------------|
|
||||||
|
| **Package/Identity/Name** | `Package.appxmanifest` → `Identity Name` and `.csproj` → `AppxPackageIdentityName` |
|
||||||
|
| **Package/Identity/Publisher** | `Package.appxmanifest` → `Identity Publisher` and `.csproj` → `AppxPackagePublisher` |
|
||||||
|
| **Package/Properties/PublisherDisplayName** | `Package.appxmanifest` → `Properties PublisherDisplayName` |
|
||||||
|
|
||||||
|
## Step 2: Prepare the Extension
|
||||||
|
|
||||||
|
### Update `Package.appxmanifest`
|
||||||
|
|
||||||
|
Replace the placeholder identity values with your Partner Center values:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Identity
|
||||||
|
Name="YOUR_PACKAGE_IDENTITY_NAME_HERE"
|
||||||
|
Publisher="YOUR_PACKAGE_IDENTITY_PUBLISHER_HERE"
|
||||||
|
Version="0.0.1.0" />
|
||||||
|
```
|
||||||
|
|
||||||
|
And update the publisher display name:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Properties>
|
||||||
|
<DisplayName>Your Extension Name</DisplayName>
|
||||||
|
<PublisherDisplayName>YOUR_PUBLISHER_DISPLAY_NAME_HERE</PublisherDisplayName>
|
||||||
|
<!-- ... -->
|
||||||
|
</Properties>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update `.csproj`
|
||||||
|
|
||||||
|
Add or update the following properties in your `.csproj` file:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<PropertyGroup>
|
||||||
|
<AppxPackageIdentityName>YOUR_PACKAGE_IDENTITY_NAME_HERE</AppxPackageIdentityName>
|
||||||
|
<AppxPackagePublisher>YOUR_PACKAGE_IDENTITY_PUBLISHER_HERE</AppxPackagePublisher>
|
||||||
|
<AppxPackageVersion>0.0.1.0</AppxPackageVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Image Assets ItemGroup
|
||||||
|
|
||||||
|
Ensure all image assets are included in the package by updating the `ItemGroup`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="Assets\**\*.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** The `Assets` folder should contain your Store logos and extension icons at the required sizes (44x44, 150x150, etc.). You can generate these from a single high-resolution image.
|
||||||
|
|
||||||
|
## Step 3: Build MSIX Packages
|
||||||
|
|
||||||
|
Build for both x64 and ARM64 architectures:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# x64 build
|
||||||
|
dotnet build --configuration Release -p:GenerateAppxPackageOnBuild=true -p:Platform=x64 -p:AppxPackageDir="AppPackages\x64\"
|
||||||
|
|
||||||
|
# ARM64 build
|
||||||
|
dotnet build --configuration Release -p:GenerateAppxPackageOnBuild=true -p:Platform=ARM64 -p:AppxPackageDir="AppPackages\ARM64\"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the MSIX files were created:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dir AppPackages -Recurse -Filter "*.msix"
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see two `.msix` files, one for each architecture.
|
||||||
|
|
||||||
|
## Step 4: Create MSIX Bundle
|
||||||
|
|
||||||
|
### Create the bundle mapping file
|
||||||
|
|
||||||
|
Create a file named `bundle_mapping.txt` that maps each MSIX to its architecture:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Files]
|
||||||
|
"AppPackages\x64\YourExtension_0.0.1.0_x64\YourExtension_0.0.1.0_x64.msix" "YourExtension_0.0.1.0_x64.msix"
|
||||||
|
"AppPackages\ARM64\YourExtension_0.0.1.0_ARM64\YourExtension_0.0.1.0_ARM64.msix" "YourExtension_0.0.1.0_ARM64.msix"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Update the paths and filenames to match your actual build output. Check the `AppPackages` directory structure after building.
|
||||||
|
|
||||||
|
### Run makeappx
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
makeappx bundle /f bundle_mapping.txt /p YourExtension_0.0.1.0_Bundle.msixbundle
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** `makeappx.exe` is included with the Windows SDK. If it's not in your PATH, find it at:
|
||||||
|
> `C:\Program Files (x86)\Windows Kits\10\bin\<version>\x64\makeappx.exe`
|
||||||
|
|
||||||
|
## Step 5: Submit to Partner Center
|
||||||
|
|
||||||
|
1. Go to [Partner Center](https://partner.microsoft.com/dashboard/home)
|
||||||
|
2. Navigate to your app → **Start a new submission**
|
||||||
|
3. In **Packages**, upload your `.msixbundle` file
|
||||||
|
4. In **Store Listings** → **Description**, include a note like:
|
||||||
|
|
||||||
|
> `YourExtension` integrates with the Windows Command Palette to provide [describe your extension's functionality]. Requires PowerToys with Command Palette enabled.
|
||||||
|
|
||||||
|
5. In **Notes for certification**, add testing instructions:
|
||||||
|
|
||||||
|
> This extension requires Microsoft PowerToys (available from the Microsoft Store or https://github.com/microsoft/PowerToys) with the Command Palette feature enabled. To test:
|
||||||
|
> 1. Install PowerToys and enable Command Palette
|
||||||
|
> 2. Install this extension
|
||||||
|
> 3. Open Command Palette (Win+Alt+Space by default)
|
||||||
|
> 4. Search for [your extension's commands]
|
||||||
|
|
||||||
|
6. Set **Availability** and pricing as appropriate
|
||||||
|
7. Click **Submit for certification**
|
||||||
|
|
||||||
|
Certification typically takes 1–3 business days.
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
Before submitting, verify:
|
||||||
|
|
||||||
|
- [ ] Partner Center identity values match exactly in both `Package.appxmanifest` and `.csproj`
|
||||||
|
- [ ] `AppxPackageVersion` is set correctly and incremented from any previous submission
|
||||||
|
- [ ] Both x64 and ARM64 MSIX files are built successfully
|
||||||
|
- [ ] MSIX bundle is created without errors
|
||||||
|
- [ ] Extension installs and runs correctly from the MSIX package locally
|
||||||
|
- [ ] Store listing includes clear description mentioning Command Palette integration
|
||||||
|
- [ ] Testing instructions mention the PowerToys/Command Palette prerequisite
|
||||||
|
- [ ] All required Store logos and screenshots are provided
|
||||||
|
- [ ] Privacy policy URL is set (if your extension accesses network or user data)
|
||||||
|
|
||||||
|
## Store-Only Discovery Limitations
|
||||||
|
|
||||||
|
> **Important:** Command Palette cannot currently search for extensions published only to the Microsoft Store via its built-in browse experience. Users can find Store-published extensions through:
|
||||||
|
>
|
||||||
|
> - Direct Store link shared by the developer
|
||||||
|
> - The Store's extension tag URL:
|
||||||
|
> ```
|
||||||
|
> ms-windows-store://assoc/?Tags=AppExtension-com.microsoft.commandpalette
|
||||||
|
> ```
|
||||||
|
> - Searching the Store app directly
|
||||||
|
>
|
||||||
|
> For discoverability within Command Palette's browse experience, also publish to WinGet.
|
||||||
|
> See [winget-publishing.md](winget-publishing.md) for details.
|
||||||
|
|
||||||
|
## Updating Your Extension
|
||||||
|
|
||||||
|
To publish an update:
|
||||||
|
|
||||||
|
1. Increment the version in `.csproj` (`AppxPackageVersion`) and `Package.appxmanifest`
|
||||||
|
2. Rebuild MSIX packages for both architectures
|
||||||
|
3. Recreate the MSIX bundle with updated filenames
|
||||||
|
4. Create a new submission in Partner Center and upload the new bundle
|
||||||
|
5. Submit for certification
|
||||||
|
|
||||||
|
The Store will automatically update users who have installed your extension.
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
# WinGet Publishing Guide
|
||||||
|
|
||||||
|
Complete step-by-step guide for publishing your Command Palette extension to WinGet for `winget install` discovery and installation.
|
||||||
|
|
||||||
|
## Why WinGet?
|
||||||
|
|
||||||
|
Publishing to WinGet enables:
|
||||||
|
|
||||||
|
- Users to install via `winget install YourPublisher.YourExtension`
|
||||||
|
- Discovery directly inside Command Palette's built-in browse experience
|
||||||
|
- Automatic update detection via WinGet manifests
|
||||||
|
|
||||||
|
## Step 1: Prepare the Project for Unpackaged Distribution
|
||||||
|
|
||||||
|
WinGet distribution uses an unpackaged (EXE-based) build instead of MSIX.
|
||||||
|
|
||||||
|
### Update `.csproj`
|
||||||
|
|
||||||
|
Remove any existing `<PublishProfile>` property and add unpackaged mode:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Remove or comment out this line if present: -->
|
||||||
|
<!-- <PublishProfile>win-$(Platform)</PublishProfile> -->
|
||||||
|
|
||||||
|
<!-- Add this for unpackaged distribution: -->
|
||||||
|
<WindowsPackageType>None</WindowsPackageType>
|
||||||
|
</PropertyGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Note Your CLSID
|
||||||
|
|
||||||
|
Find the `[Guid("...")]` attribute in your main `.cs` file (e.g., `SampleExtension.cs`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Guid("YOUR-GUID-HERE")]
|
||||||
|
public sealed partial class SampleExtension : IExtension
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll need this exact GUID for the installer script. It must match across all files.
|
||||||
|
|
||||||
|
## Step 2: Create Installer Scripts
|
||||||
|
|
||||||
|
### Inno Setup Script: `setup-template.iss`
|
||||||
|
|
||||||
|
Create this file in your project root. Replace all `TODO` placeholders with your values:
|
||||||
|
|
||||||
|
```iss
|
||||||
|
; Inno Setup script for Command Palette extension
|
||||||
|
|
||||||
|
#define MyAppName "TODO_YOUR_EXTENSION_NAME"
|
||||||
|
#define MyAppVersion "TODO_YOUR_VERSION"
|
||||||
|
#define MyAppPublisher "TODO_YOUR_PUBLISHER_NAME"
|
||||||
|
#define MyAppURL "TODO_YOUR_PROJECT_URL"
|
||||||
|
#define MyAppCLSID "TODO_YOUR_CLSID_WITH_BRACES"
|
||||||
|
; Example CLSID: {12345678-1234-1234-1234-123456789012}
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
AppId={#MyAppCLSID}
|
||||||
|
AppName={#MyAppName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
AppPublisherURL={#MyAppURL}
|
||||||
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
|
OutputBaseFilename={#MyAppName}_{#MyAppVersion}_{#SetupSetting("ArchitecturesAllowed")}
|
||||||
|
Compression=lzma
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
PrivilegesRequired=lowest
|
||||||
|
OutputDir=Installer
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
|
[Registry]
|
||||||
|
; Register the COM server for Command Palette discovery
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\CLSID\{#MyAppCLSID}"; ValueType: string; ValueName: ""; ValueData: "{#MyAppName}"; Flags: uninsdeletekey
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\CLSID\{#MyAppCLSID}\InprocServer32"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppName}.dll"; Flags: uninsdeletekey
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\CLSID\{#MyAppCLSID}\InprocServer32"; ValueType: string; ValueName: "ThreadingModel"; ValueData: "Both"; Flags: uninsdeletekey
|
||||||
|
|
||||||
|
[UninstallDelete]
|
||||||
|
Type: filesandordirs; Name: "{app}"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important:** The `AppId` must use your CLSID wrapped in braces. The registry entries register your extension's COM server so Command Palette can discover it.
|
||||||
|
|
||||||
|
### Build Script: `build-exe.ps1`
|
||||||
|
|
||||||
|
Create this PowerShell script in your project root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Builds EXE installers for x64 and ARM64 using dotnet publish and Inno Setup.
|
||||||
|
.DESCRIPTION
|
||||||
|
Publishes the project for both architectures, then runs Inno Setup to create
|
||||||
|
EXE installers suitable for WinGet submission.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Configuration = "Release",
|
||||||
|
[string]$Version = "0.0.1"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$projectName = (Get-ChildItem -Filter "*.csproj" | Select-Object -First 1).BaseName
|
||||||
|
if (-not $projectName) {
|
||||||
|
Write-Error "No .csproj file found in the current directory."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$architectures = @("x64", "arm64")
|
||||||
|
|
||||||
|
foreach ($arch in $architectures) {
|
||||||
|
Write-Host "`n=== Building $arch ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Publish
|
||||||
|
Write-Host "Publishing for $arch..."
|
||||||
|
dotnet publish -c $Configuration -r "win-$arch" -o "publish" --self-contained=false
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "dotnet publish failed for $arch"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create installer
|
||||||
|
Write-Host "Creating installer for $arch..."
|
||||||
|
$issFile = "setup-template.iss"
|
||||||
|
if (-not (Test-Path $issFile)) {
|
||||||
|
Write-Error "Inno Setup script not found: $issFile"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$archFlag = if ($arch -eq "arm64") { "arm64" } else { "x64" }
|
||||||
|
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
|
||||||
|
/DMyAppVersion="$Version" `
|
||||||
|
/DArchitecturesAllowed="$archFlag" `
|
||||||
|
$issFile
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Inno Setup failed for $arch"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean publish directory for next architecture
|
||||||
|
Remove-Item -Recurse -Force "publish" -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Write-Host "=== $arch complete ===" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nInstallers created in the 'Installer' directory:" -ForegroundColor Cyan
|
||||||
|
Get-ChildItem -Path "Installer" -Filter "*.exe" | ForEach-Object { Write-Host " $_" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Build EXE Installers
|
||||||
|
|
||||||
|
Run the build script from your project directory:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build-exe.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces two EXE files in the `Installer` directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
Installer\YourExtension_0.0.1_x64.exe
|
||||||
|
Installer\YourExtension_0.0.1_arm64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify both installers work by running them locally and confirming your extension appears in Command Palette.
|
||||||
|
|
||||||
|
## Step 4: Create a GitHub Release
|
||||||
|
|
||||||
|
Tag your repository with the version and create a release with the EXE files:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Tag the release
|
||||||
|
git tag -a v0.0.1 -m "Release v0.0.1"
|
||||||
|
git push origin v0.0.1
|
||||||
|
|
||||||
|
# Create release and upload assets (requires GitHub CLI)
|
||||||
|
gh release create v0.0.1 `
|
||||||
|
"Installer\YourExtension_0.0.1_x64.exe" `
|
||||||
|
"Installer\YourExtension_0.0.1_arm64.exe" `
|
||||||
|
--title "v0.0.1" `
|
||||||
|
--notes "Initial release of YourExtension for Command Palette."
|
||||||
|
```
|
||||||
|
|
||||||
|
After creating the release, copy the download URLs for both EXE files — you'll need them for the WinGet submission.
|
||||||
|
|
||||||
|
## Step 5: Submit to WinGet
|
||||||
|
|
||||||
|
Use `wingetcreate` to generate a WinGet manifest and submit a pull request:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wingetcreate new "<URL_TO_x64.exe>" "<URL_TO_arm64.exe>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`wingetcreate` will interactively prompt you for:
|
||||||
|
|
||||||
|
| Prompt | Example Value |
|
||||||
|
|--------|---------------|
|
||||||
|
| **PackageIdentifier** | `YourPublisher.YourExtension` |
|
||||||
|
| **PackageVersion** | `0.0.1` |
|
||||||
|
| **PackageLocale** | `en-US` |
|
||||||
|
| **Publisher** | `Your Name` |
|
||||||
|
| **PackageName** | `YourExtension for Command Palette` |
|
||||||
|
| **License** | `MIT` |
|
||||||
|
| **ShortDescription** | `A Command Palette extension that does X` |
|
||||||
|
|
||||||
|
After answering all prompts, `wingetcreate` will create a PR against the [winget-pkgs](https://github.com/microsoft/winget-pkgs) repository.
|
||||||
|
|
||||||
|
## Step 6: Add the Command Palette Tag (CRITICAL)
|
||||||
|
|
||||||
|
> **This step is required for your extension to appear in Command Palette's browse experience.**
|
||||||
|
|
||||||
|
After `wingetcreate` generates the manifest files, you **must** edit each `.locale.*.yaml` file to add the Command Palette tag.
|
||||||
|
|
||||||
|
In every locale YAML file (e.g., `YourPublisher.YourExtension.locale.en-US.yaml`), add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Tags:
|
||||||
|
- windows-commandpalette-extension
|
||||||
|
```
|
||||||
|
|
||||||
|
Example of a complete locale file with the tag:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json
|
||||||
|
PackageIdentifier: YourPublisher.YourExtension
|
||||||
|
PackageVersion: 0.0.1
|
||||||
|
PackageLocale: en-US
|
||||||
|
Publisher: Your Name
|
||||||
|
PackageName: YourExtension for Command Palette
|
||||||
|
License: MIT
|
||||||
|
ShortDescription: A Command Palette extension that does X
|
||||||
|
Tags:
|
||||||
|
- windows-commandpalette-extension
|
||||||
|
ManifestType: defaultLocale
|
||||||
|
ManifestVersion: 1.6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this tag, Command Palette will not discover your extension in its browse experience.
|
||||||
|
|
||||||
|
## Step 7: Ensure WindowsAppSdk Dependency
|
||||||
|
|
||||||
|
Your WinGet manifest must declare a dependency on the Windows App SDK so it gets installed automatically. In the `installer.yaml` manifest file, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Dependencies:
|
||||||
|
PackageDependencies:
|
||||||
|
- PackageIdentifier: Microsoft.WindowsAppRuntime.1.7
|
||||||
|
MinimumVersion: 7001.632.252.0
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Update the version number to match the Windows App SDK version your project targets. Check your `.csproj` for the `WindowsAppSDK` package version.
|
||||||
|
|
||||||
|
## Step 8: GitHub Actions Automation (Optional)
|
||||||
|
|
||||||
|
Automate your build, release, and WinGet submission process with GitHub Actions.
|
||||||
|
|
||||||
|
### Release Workflow: `.github/workflows/release-extension.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Release Extension
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
PROJECT_NAME: YourExtension
|
||||||
|
DOTNET_VERSION: '9.0.x'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: [x64, arm64]
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
|
- name: Install Inno Setup
|
||||||
|
run: choco install innosetup -y --no-progress
|
||||||
|
|
||||||
|
- name: Detect version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
$tag = "${{ github.ref_name }}" -replace '^v', ''
|
||||||
|
echo "VERSION=$tag" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
run: |
|
||||||
|
dotnet publish -c Release -r win-${{ matrix.arch }} -o publish --self-contained=false
|
||||||
|
|
||||||
|
- name: Create installer
|
||||||
|
run: |
|
||||||
|
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" `
|
||||||
|
/DMyAppVersion="${{ steps.version.outputs.VERSION }}" `
|
||||||
|
/DArchitecturesAllowed="${{ matrix.arch }}" `
|
||||||
|
setup-template.iss
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: installer-${{ matrix.arch }}
|
||||||
|
path: Installer/*.exe
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: artifacts/*.exe
|
||||||
|
generate_release_notes: true
|
||||||
|
|
||||||
|
winget-update:
|
||||||
|
needs: release
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Detect version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
$tag = "${{ github.ref_name }}" -replace '^v', ''
|
||||||
|
echo "VERSION=$tag" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Update WinGet manifest
|
||||||
|
run: |
|
||||||
|
$baseUrl = "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}"
|
||||||
|
wingetcreate update YourPublisher.YourExtension `
|
||||||
|
--version ${{ steps.version.outputs.VERSION }} `
|
||||||
|
--urls "$baseUrl/${{ env.PROJECT_NAME }}_${{ steps.version.outputs.VERSION }}_x64.exe" "$baseUrl/${{ env.PROJECT_NAME }}_${{ steps.version.outputs.VERSION }}_arm64.exe" `
|
||||||
|
--submit `
|
||||||
|
--token ${{ secrets.WINGET_PAT }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Secrets
|
||||||
|
|
||||||
|
| Secret | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `WINGET_PAT` | GitHub Personal Access Token with `public_repo` scope, used by `wingetcreate` to submit PRs to `microsoft/winget-pkgs` |
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Push a version tag** (e.g., `git tag v0.0.2 && git push origin v0.0.2`)
|
||||||
|
2. **Build job** runs in parallel for x64 and ARM64, creating EXE installers
|
||||||
|
3. **Release job** creates a GitHub Release and uploads the EXE files
|
||||||
|
4. **WinGet update job** automatically submits an updated manifest to `winget-pkgs`
|
||||||
|
|
||||||
|
> **Note:** The `winget-update` job uses `wingetcreate update` (not `new`) because it assumes you've already submitted your initial manifest manually. For the first submission, follow Steps 5–7 above.
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
Before submitting to WinGet, verify:
|
||||||
|
|
||||||
|
- [ ] `.csproj` has `<WindowsPackageType>None</WindowsPackageType>` set
|
||||||
|
- [ ] CLSID in `setup-template.iss` matches the `[Guid("...")]` in your main `.cs` file
|
||||||
|
- [ ] Both x64 and ARM64 EXE installers build successfully
|
||||||
|
- [ ] Installer registers the COM server correctly (check `HKCU\Software\Classes\CLSID\{your-clsid}`)
|
||||||
|
- [ ] Extension appears in Command Palette after installing via EXE
|
||||||
|
- [ ] Extension is removed from Command Palette after uninstalling
|
||||||
|
- [ ] GitHub Release contains both EXE files with correct download URLs
|
||||||
|
- [ ] WinGet manifest includes `windows-commandpalette-extension` tag
|
||||||
|
- [ ] WinGet manifest includes `WindowsAppRuntime` dependency
|
||||||
|
- [ ] `winget validate` passes on all manifest files
|
||||||
|
|
||||||
|
## Updating Your Extension on WinGet
|
||||||
|
|
||||||
|
For subsequent releases:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wingetcreate update YourPublisher.YourExtension `
|
||||||
|
--version "0.0.2" `
|
||||||
|
--urls "<URL_TO_NEW_x64.exe>" "<URL_TO_NEW_arm64.exe>" `
|
||||||
|
--submit
|
||||||
|
```
|
||||||
|
|
||||||
|
Or simply push a new version tag if you've set up the GitHub Actions workflow above.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Extension not appearing in CmdPal browse | Verify the `windows-commandpalette-extension` tag is in your locale YAML |
|
||||||
|
| COM registration fails | Check that the CLSID matches exactly and registry paths are correct |
|
||||||
|
| `wingetcreate` validation errors | Run `winget validate --manifest <path>` and fix reported issues |
|
||||||
|
| Installer doesn't run silently | Add `/VERYSILENT /SUPPRESSMSGBOXES` flags for silent install support |
|
||||||
|
| Missing WindowsAppSdk at runtime | Ensure the `PackageDependencies` section is in your installer manifest |
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
.vs/
|
||||||
|
dist/
|
||||||
|
Olive/AppPackages/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.pfx
|
||||||
|
*.cer
|
||||||
|
*.msix
|
||||||
|
*.msixbundle
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Rider ignored files
|
||||||
|
/modules.xml
|
||||||
|
/contentModel.xml
|
||||||
|
/.idea.Olive.iml
|
||||||
|
/projectSettingsUpdater.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Olive
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="UserContentModel">
|
||||||
|
<attachedFolders />
|
||||||
|
<explicitIncludes />
|
||||||
|
<explicitExcludes />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredPackages">
|
||||||
|
<value>
|
||||||
|
<list size="1">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="socketserver" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredErrors">
|
||||||
|
<list>
|
||||||
|
<option value="N802" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="VulnerableLibrariesLocal" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="isIgnoringEnabled" value="true" />
|
||||||
|
<option name="ignoredModules">
|
||||||
|
<list>
|
||||||
|
<option value="poc-flux-mjpeg" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="ignoredPackages">
|
||||||
|
<list>
|
||||||
|
<option value="null:opencv-python:4.9.0.80" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="ignoredReasons">
|
||||||
|
<list>
|
||||||
|
<option value="Not exploitable" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="ASK" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Platforms>x64;ARM64</Platforms>
|
||||||
|
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||||
|
<AnalysisMode>Recommended</AnalysisMode>
|
||||||
|
<_SkipUpgradeNetAnalyzersNuGetWarning>true</_SkipUpgradeNetAnalyzersNuGetWarning>
|
||||||
|
<NuGetAuditMode>direct</NuGetAuditMode>
|
||||||
|
<PlatformTarget>$(Platform)</PlatformTarget>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.11.260520004" />
|
||||||
|
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
|
||||||
|
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
|
||||||
|
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
|
||||||
|
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
|
||||||
|
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||||
|
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
|
||||||
|
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||||
|
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.13.35507.96 d17.13
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Olive", "Olive\Olive.csproj", "{79F86DE5-70B1-4EC1-9832-DF428B55E466}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|ARM64 = Debug|ARM64
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|ARM64 = Release|ARM64
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|ARM64.ActiveCfg = Debug|ARM64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|ARM64.Build.0 = Debug|ARM64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|ARM64.Deploy.0 = Debug|ARM64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x64.ActiveCfg = Debug|x64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x64.Build.0 = Debug|x64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x64.Deploy.0 = Debug|x64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.ActiveCfg = Debug|x86
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.Build.0 = Debug|x86
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.Deploy.0 = Debug|x86
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.ActiveCfg = Release|ARM64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.Build.0 = Release|ARM64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.Deploy.0 = Release|ARM64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x64.ActiveCfg = Release|x64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x64.Build.0 = Release|x64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x64.Deploy.0 = Release|x64
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.ActiveCfg = Release|x86
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Build.0 = Release|x86
|
||||||
|
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Deploy.0 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {CEDBC581-5818-4350-BC8A-A1ECE687D357}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1 @@
|
|||||||
|
MainPackage=C:\Users\jnih\Downloads\gifBrowser\Olive\bin\x64\Release\net10.0-windows10.0.22621.0\win-x64\Olive_0.0.7.0_x64.msix
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
using Olive.Klipy;
|
||||||
|
using Olive.Services;
|
||||||
|
|
||||||
|
namespace Olive.Commands;
|
||||||
|
|
||||||
|
internal sealed partial class CopyGifCommand : InvokableCommand
|
||||||
|
{
|
||||||
|
private readonly KlipyGif _gif;
|
||||||
|
private readonly GifCache _cache;
|
||||||
|
|
||||||
|
public CopyGifCommand(KlipyGif gif, GifCache cache)
|
||||||
|
{
|
||||||
|
_gif = gif;
|
||||||
|
_cache = cache;
|
||||||
|
Name = "Copy GIF";
|
||||||
|
Icon = new IconInfo("\uE8C8");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ICommandResult Invoke()
|
||||||
|
{
|
||||||
|
_ = CopyAsync();
|
||||||
|
return CommandResult.KeepOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyAsync()
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ShowStatus("Downloading GIF...", MessageState.Info, 800);
|
||||||
|
var path = await _cache.GetOrDownloadAsync(_gif, cancellation.Token).ConfigureAwait(false);
|
||||||
|
await ClipboardService.CopyGifFileAsync(path, _gif.GifUrl, _gif.Title, cancellation.Token).ConfigureAwait(false);
|
||||||
|
ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
ShowStatus("Copy cancelled or timed out.", MessageState.Warning, 2500);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
ShowStatus("Could not copy this GIF.", MessageState.Error, 3500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowStatus(string message, MessageState state, int duration)
|
||||||
|
{
|
||||||
|
new ToastStatusMessage(new StatusMessage { Message = message, State = state }) { Duration = duration }.Show();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.IO;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Net.Http;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
|
||||||
|
namespace Olive.Helpers;
|
||||||
|
|
||||||
|
internal sealed partial class SettingsManager : JsonSettingsManager
|
||||||
|
{
|
||||||
|
private const string Namespace = "Olive";
|
||||||
|
|
||||||
|
private readonly TextSetting _klipyApiKey = new(
|
||||||
|
Namespaced(nameof(KlipyApiKey)),
|
||||||
|
"Klipy API key",
|
||||||
|
"Private key used to call the Klipy API. It stays stored locally in Olive settings.",
|
||||||
|
string.Empty)
|
||||||
|
{
|
||||||
|
Placeholder = "Paste your Klipy API key here",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Namespaced(string propertyName) => $"{Namespace}.{propertyName}";
|
||||||
|
|
||||||
|
public string KlipyApiKey
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
LoadSettings();
|
||||||
|
return _klipyApiKey.Value ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey);
|
||||||
|
|
||||||
|
public string SettingsPath => FilePath;
|
||||||
|
|
||||||
|
internal static string SettingsJsonPath()
|
||||||
|
{
|
||||||
|
var directory = Utilities.BaseSettingsPath("Olive");
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
return Path.Combine(directory, "settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettingsManager()
|
||||||
|
{
|
||||||
|
FilePath = SettingsJsonPath();
|
||||||
|
|
||||||
|
Settings.Add(_klipyApiKey);
|
||||||
|
|
||||||
|
// Load settings from file upon initialization
|
||||||
|
LoadSettings();
|
||||||
|
|
||||||
|
Settings.SettingsChanged += (_, _) => SaveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace Olive.Klipy;
|
||||||
|
|
||||||
|
internal sealed class KlipyClient
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "https://api.klipy.com";
|
||||||
|
private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(12) };
|
||||||
|
|
||||||
|
public static async Task<KlipySearchResult> SearchAsync(string apiKey, string query, int page, int perPage, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The Klipy API key is missing. Set it in Olive settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestUri = BuildSearchUri(apiKey, query, page, perPage);
|
||||||
|
|
||||||
|
using var response = await HttpClient.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||||
|
{
|
||||||
|
return new KlipySearchResult([], HasMore: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"Klipy returned {(int)response.StatusCode} {response.ReasonPhrase}.", null, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync(KlipyJsonContext.Default.KlipySearchResponse, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (payload is null || !payload.Result)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException("Klipy did not return a usable result.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawItems = payload.Data?.Items ?? [];
|
||||||
|
var hasMore = payload.Data?.HasNext ?? rawItems.Length >= perPage;
|
||||||
|
return new KlipySearchResult(ConvertResults(rawItems), hasMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task DownloadGifAsync(Uri gifUrl, string destinationPath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await HttpClient.GetAsync(gifUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"Download failed ({(int)response.StatusCode}).", null, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var output = File.Create(destinationPath);
|
||||||
|
await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri BuildSearchUri(string apiKey, string query, int page, int perPage)
|
||||||
|
{
|
||||||
|
var endpoint = $"{BaseUrl}/api/v1/{Uri.EscapeDataString(apiKey)}/gifs/search";
|
||||||
|
var parameters = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["page"] = page.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["per_page"] = perPage.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["q"] = query,
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", parameters.Select(pair => string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")));
|
||||||
|
|
||||||
|
return new Uri($"{endpoint}?{queryString}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<KlipyGif> ConvertResults(KlipyItem[]? items)
|
||||||
|
{
|
||||||
|
if (items is null || items.Length == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var gifs = new List<KlipyGif>(items.Length);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var id = !string.IsNullOrWhiteSpace(item.Slug) ? item.Slug : item.Id?.ToString(CultureInfo.InvariantCulture);
|
||||||
|
var thumbnailUrl = item.File?.Sm?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Hd?.Gif?.Url;
|
||||||
|
var fullUrl = item.File?.Hd?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Sm?.Gif?.Url;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(id)
|
||||||
|
|| !Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailGifUrl)
|
||||||
|
|| !Uri.TryCreate(fullUrl, UriKind.Absolute, out var gifUrl))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = string.IsNullOrWhiteSpace(item.Title)
|
||||||
|
? "GIF Klipy"
|
||||||
|
: WebUtility.HtmlDecode(item.Title).Trim();
|
||||||
|
|
||||||
|
gifs.Add(new KlipyGif(id, title, thumbnailGifUrl, gifUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return gifs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Olive.Klipy;
|
||||||
|
|
||||||
|
internal sealed record KlipyGif(
|
||||||
|
string Id,
|
||||||
|
string Title,
|
||||||
|
Uri ThumbnailGifUrl,
|
||||||
|
Uri GifUrl);
|
||||||
|
|
||||||
|
internal sealed record KlipySearchResult(
|
||||||
|
IReadOnlyList<KlipyGif> Gifs,
|
||||||
|
bool HasMore);
|
||||||
|
|
||||||
|
internal sealed record KlipySearchResponse(
|
||||||
|
[property: JsonPropertyName("result")] bool Result,
|
||||||
|
[property: JsonPropertyName("data")] KlipySearchData? Data);
|
||||||
|
|
||||||
|
internal sealed record KlipySearchData(
|
||||||
|
[property: JsonPropertyName("data")] KlipyItem[]? Items,
|
||||||
|
[property: JsonPropertyName("has_next")] bool? HasNext);
|
||||||
|
|
||||||
|
internal sealed record KlipyItem(
|
||||||
|
[property: JsonPropertyName("id")] long? Id,
|
||||||
|
[property: JsonPropertyName("slug")] string? Slug,
|
||||||
|
[property: JsonPropertyName("title")] string? Title,
|
||||||
|
[property: JsonPropertyName("file")] KlipyFile? File);
|
||||||
|
|
||||||
|
internal sealed record KlipyFile(
|
||||||
|
[property: JsonPropertyName("hd")] KlipyFileSize? Hd,
|
||||||
|
[property: JsonPropertyName("md")] KlipyFileSize? Md,
|
||||||
|
[property: JsonPropertyName("sm")] KlipyFileSize? Sm);
|
||||||
|
|
||||||
|
internal sealed record KlipyFileSize(
|
||||||
|
[property: JsonPropertyName("gif")] KlipyMediaFormat? Gif);
|
||||||
|
|
||||||
|
internal sealed record KlipyMediaFormat(
|
||||||
|
[property: JsonPropertyName("url")] string? Url);
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(KlipySearchResponse))]
|
||||||
|
internal sealed partial class KlipyJsonContext : JsonSerializerContext;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<RootNamespace>Olive</RootNamespace>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
|
||||||
|
<WindowsSdkPackageVersion>10.0.26100.68-preview</WindowsSdkPackageVersion>
|
||||||
|
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
|
||||||
|
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
|
||||||
|
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
|
||||||
|
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||||
|
|
||||||
|
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
||||||
|
<EnableMsixTooling>true</EnableMsixTooling>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<NoWarn>$(NoWarn);APPX1707</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="Assets\SplashScreen.scale-200.png" />
|
||||||
|
<Content Include="Assets\AppLogo150.scale-200.png" />
|
||||||
|
<Content Include="Assets\AppLogo44.scale-200.png" />
|
||||||
|
<Content Include="Assets\AppLogo44.targetsize-24_altform-unplated.png" />
|
||||||
|
<Content Include="Assets\StoreLogo.png" />
|
||||||
|
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Manifest Include="$(ApplicationManifest)" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
|
||||||
|
Tools extension to be activated for this project even if the Windows App SDK Nuget
|
||||||
|
package has not yet been restored.
|
||||||
|
-->
|
||||||
|
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||||
|
<ProjectCapability Include="Msix" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.CommandPalette.Extensions" />
|
||||||
|
<PackageReference Include="Microsoft.Windows.CsWinRT" />
|
||||||
|
<PackageReference Include="Shmuelie.WinRTServer" />
|
||||||
|
|
||||||
|
<!-- Needed to enable building an MSIX package -->
|
||||||
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.MSIX">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
|
||||||
|
Explorer "Package and Publish" context menu entry to be enabled for this project even if
|
||||||
|
the Windows App SDK Nuget package has not yet been restored.
|
||||||
|
-->
|
||||||
|
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||||
|
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
|
|
||||||
|
<CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled>
|
||||||
|
<CsWinRTAotWarningLevel>2</CsWinRTAotWarningLevel>
|
||||||
|
<!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection -->
|
||||||
|
<WarningsNotAsErrors>IL2081;$(WarningsNotAsErrors)</WarningsNotAsErrors>
|
||||||
|
|
||||||
|
<!-- When publishing trimmed, make sure to treat trimming warnings as build errors -->
|
||||||
|
<ILLinkTreatWarningsAsErrors>true</ILLinkTreatWarningsAsErrors>
|
||||||
|
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
||||||
|
<!-- In Debug builds, trimming is disabled by default, but all the trim &
|
||||||
|
AOT warnings are enabled. This gives debug builds a tighter inner loop,
|
||||||
|
while at least warning about future trim violations -->
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
|
||||||
|
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
|
||||||
|
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
|
||||||
|
<EnableAotAnalyzer>true</EnableAotAnalyzer>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)'!='Debug'">
|
||||||
|
<!-- Windows Forms clipboard access is used to copy real GIF files; it is not trim-compatible. -->
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
|
||||||
|
<!-- In release, also ignore the aforementioned ILLink warning -->
|
||||||
|
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
using Olive.Helpers;
|
||||||
|
using Olive.Pages;
|
||||||
|
|
||||||
|
namespace Olive;
|
||||||
|
|
||||||
|
public sealed partial class OliveCommandsProvider : CommandProvider
|
||||||
|
{
|
||||||
|
private readonly SettingsManager _settingsManager = new();
|
||||||
|
|
||||||
|
public OliveCommandsProvider()
|
||||||
|
{
|
||||||
|
DisplayName = "Olive";
|
||||||
|
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||||
|
Settings = _settingsManager.Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ICommandItem[] TopLevelCommands()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new CommandItem(new GifPickerPage(_settingsManager))
|
||||||
|
{
|
||||||
|
Title = "Olive GIF Picker",
|
||||||
|
Subtitle = "Browse through lots of GIFs and find the one that suits you best!",
|
||||||
|
MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)],
|
||||||
|
Icon = Icon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
|
||||||
|
namespace Olive;
|
||||||
|
|
||||||
|
[Guid("C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A")]
|
||||||
|
public sealed partial class OliveExtension : IExtension, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ManualResetEvent _extensionDisposedEvent;
|
||||||
|
|
||||||
|
private readonly OliveCommandsProvider _provider = new();
|
||||||
|
|
||||||
|
public OliveExtension(ManualResetEvent extensionDisposedEvent)
|
||||||
|
{
|
||||||
|
_extensionDisposedEvent = extensionDisposedEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? GetProvider(ProviderType providerType)
|
||||||
|
{
|
||||||
|
return providerType switch
|
||||||
|
{
|
||||||
|
ProviderType.Commands => _provider,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _extensionDisposedEvent.Set();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<Package
|
||||||
|
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||||
|
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||||
|
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
|
||||||
|
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
|
||||||
|
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||||
|
IgnorableNamespaces="uap uap3 rescap">
|
||||||
|
|
||||||
|
<Identity
|
||||||
|
Name="Olive"
|
||||||
|
Publisher="CN=OlivePrivate"
|
||||||
|
Version="0.0.32.0" />
|
||||||
|
<Properties>
|
||||||
|
<DisplayName>Olive</DisplayName>
|
||||||
|
<PublisherDisplayName>Private</PublisherDisplayName>
|
||||||
|
<Logo>Assets\StoreLogo.png</Logo>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
<Dependencies>
|
||||||
|
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
</Dependencies>
|
||||||
|
|
||||||
|
<Resources>
|
||||||
|
<Resource Language="x-generate"/>
|
||||||
|
</Resources>
|
||||||
|
|
||||||
|
<Applications>
|
||||||
|
<Application Id="App"
|
||||||
|
Executable="$targetnametoken$.exe"
|
||||||
|
EntryPoint="$targetentrypoint$">
|
||||||
|
<uap:VisualElements
|
||||||
|
DisplayName="Olive"
|
||||||
|
Description="Search and copy animated GIFs from Klipy. Powered by EndMove"
|
||||||
|
BackgroundColor="transparent"
|
||||||
|
Square150x150Logo="Assets\AppLogo150.png"
|
||||||
|
Square44x44Logo="Assets\AppLogo44.png">
|
||||||
|
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||||
|
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||||
|
</uap:VisualElements>
|
||||||
|
<Extensions>
|
||||||
|
<com:Extension Category="windows.comServer">
|
||||||
|
<com:ComServer>
|
||||||
|
<com:ExeServer Executable="Olive.exe" Arguments="-RegisterProcessAsComServer" DisplayName="Olive">
|
||||||
|
<com:Class Id="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A" DisplayName="Olive" />
|
||||||
|
</com:ExeServer>
|
||||||
|
</com:ComServer>
|
||||||
|
</com:Extension>
|
||||||
|
<uap3:Extension Category="windows.appExtension">
|
||||||
|
<uap3:AppExtension Name="com.microsoft.commandpalette"
|
||||||
|
Id="Olive"
|
||||||
|
PublicFolder="Public"
|
||||||
|
DisplayName="Olive"
|
||||||
|
Description="Search and copy animated GIFs from Klipy. Powered by EndMove">
|
||||||
|
<uap3:Properties>
|
||||||
|
<CmdPalProvider>
|
||||||
|
<Activation>
|
||||||
|
<CreateInstance ClassId="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A" />
|
||||||
|
</Activation>
|
||||||
|
<SupportedInterfaces>
|
||||||
|
<Commands/>
|
||||||
|
</SupportedInterfaces>
|
||||||
|
</CmdPalProvider>
|
||||||
|
</uap3:Properties>
|
||||||
|
</uap3:AppExtension>
|
||||||
|
</uap3:Extension>
|
||||||
|
</Extensions>
|
||||||
|
</Application>
|
||||||
|
</Applications>
|
||||||
|
|
||||||
|
<Capabilities>
|
||||||
|
<Capability Name="internetClient" />
|
||||||
|
<rescap:Capability Name="runFullTrust" />
|
||||||
|
</Capabilities>
|
||||||
|
</Package>
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
using Olive.Commands;
|
||||||
|
using Olive.Helpers;
|
||||||
|
using Olive.Klipy;
|
||||||
|
using Olive.Services;
|
||||||
|
using Windows.Foundation;
|
||||||
|
using Windows.System;
|
||||||
|
|
||||||
|
namespace Olive.Pages;
|
||||||
|
|
||||||
|
internal sealed partial class GifPickerPage : IDynamicListPage
|
||||||
|
{
|
||||||
|
private const int PageSize = 30;
|
||||||
|
private const int InitialBatchPages = 1;
|
||||||
|
private const int LoadMoreBatchPages = 1;
|
||||||
|
private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
|
private readonly Lock _itemsLock = new();
|
||||||
|
private readonly GifCache _cache = new();
|
||||||
|
private readonly SettingsManager _settingsManager;
|
||||||
|
private readonly List<IListItem> _items = [];
|
||||||
|
private readonly IIconInfo _icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||||
|
private readonly IGridProperties _gridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false };
|
||||||
|
private CancellationTokenSource? _searchCancellation;
|
||||||
|
private string _searchText = string.Empty;
|
||||||
|
private string _activeSearch = string.Empty;
|
||||||
|
private string _title = "Olive";
|
||||||
|
private int _searchVersion;
|
||||||
|
private int _loadRequestId;
|
||||||
|
private int _nextPage = 1;
|
||||||
|
private int _loadedItemCount;
|
||||||
|
private bool _hasMoreItems;
|
||||||
|
private bool _isLoading;
|
||||||
|
private bool _isLoadingMore;
|
||||||
|
private ICommandItem _emptyContent;
|
||||||
|
|
||||||
|
public GifPickerPage(SettingsManager settingsManager)
|
||||||
|
{
|
||||||
|
_settingsManager = settingsManager;
|
||||||
|
_emptyContent = InitialContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||||
|
|
||||||
|
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
|
||||||
|
|
||||||
|
public IIconInfo Icon => _icon;
|
||||||
|
|
||||||
|
public string Id => string.Empty;
|
||||||
|
|
||||||
|
public string Name => "Olive GIF Picker";
|
||||||
|
|
||||||
|
public string Title => _title;
|
||||||
|
|
||||||
|
public OptionalColor AccentColor => default;
|
||||||
|
|
||||||
|
public bool IsLoading
|
||||||
|
{
|
||||||
|
get => _isLoading;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (_isLoading == value)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = value;
|
||||||
|
RaisePropChanged(nameof(IsLoading));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PlaceholderText => "Search for GIFs...";
|
||||||
|
|
||||||
|
public bool ShowDetails => false;
|
||||||
|
|
||||||
|
public bool HasMoreItems
|
||||||
|
{
|
||||||
|
get => _hasMoreItems;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (_hasMoreItems == value)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasMoreItems = value;
|
||||||
|
RaisePropChanged(nameof(HasMoreItems));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IFilters? Filters => null;
|
||||||
|
|
||||||
|
public IGridProperties GridProperties => _gridProperties;
|
||||||
|
|
||||||
|
public ICommandItem EmptyContent
|
||||||
|
{
|
||||||
|
get => _emptyContent;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
_emptyContent = value;
|
||||||
|
RaisePropChanged(nameof(EmptyContent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? SearchText
|
||||||
|
{
|
||||||
|
get => _searchText;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
value ??= string.Empty;
|
||||||
|
if (StringComparer.Ordinal.Equals(_searchText, value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_searchText = value;
|
||||||
|
UpdateSearchText(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IListItem[] GetItems()
|
||||||
|
{
|
||||||
|
lock (_itemsLock)
|
||||||
|
{
|
||||||
|
return BuildDisplayItemsLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadMore()
|
||||||
|
{
|
||||||
|
if (_isLoadingMore || !HasMoreItems || string.IsNullOrWhiteSpace(_activeSearch))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiKey = _settingsManager.KlipyApiKey;
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
HasMoreItems = false;
|
||||||
|
EmptyContent = MissingApiKeyContent();
|
||||||
|
RaiseItemsChanged(DisplayItemCount());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = _searchVersion;
|
||||||
|
_ = LoadPageBatchAsync(apiKey, _activeSearch, version, LoadMoreBatchPages, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSearchText(string newSearch)
|
||||||
|
{
|
||||||
|
CancelSearch();
|
||||||
|
|
||||||
|
var trimmedSearch = newSearch.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmedSearch))
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _searchVersion);
|
||||||
|
Interlocked.Increment(ref _loadRequestId);
|
||||||
|
IsLoading = false;
|
||||||
|
_isLoadingMore = false;
|
||||||
|
HasMoreItems = false;
|
||||||
|
_activeSearch = string.Empty;
|
||||||
|
_nextPage = 1;
|
||||||
|
ReplaceItems([]);
|
||||||
|
UpdateTitle();
|
||||||
|
EmptyContent = _settingsManager.HasKlipyApiKey ? InitialContent() : MissingApiKeyContent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = Interlocked.Increment(ref _searchVersion);
|
||||||
|
Interlocked.Increment(ref _loadRequestId);
|
||||||
|
var cancellation = new CancellationTokenSource();
|
||||||
|
_searchCancellation = cancellation;
|
||||||
|
|
||||||
|
_ = SearchAfterDebounceAsync(trimmedSearch, version, cancellation.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelSearch()
|
||||||
|
{
|
||||||
|
var cancellation = _searchCancellation;
|
||||||
|
_searchCancellation = null;
|
||||||
|
if (cancellation is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cancellation.Cancel();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellation.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SearchAfterDebounceAsync(string search, int version, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(SearchDebounceDelay, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var apiKey = _settingsManager.KlipyApiKey;
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsLoading = false;
|
||||||
|
HasMoreItems = false;
|
||||||
|
EmptyContent = MissingApiKeyContent();
|
||||||
|
ReplaceItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeSearch = search;
|
||||||
|
_nextPage = 1;
|
||||||
|
_isLoadingMore = false;
|
||||||
|
HasMoreItems = false;
|
||||||
|
IsLoading = true;
|
||||||
|
EmptyContent = LoadingContent(search);
|
||||||
|
UpdateTitle();
|
||||||
|
|
||||||
|
await LoadPageBatchAsync(apiKey, search, version, InitialBatchPages, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (version == _searchVersion)
|
||||||
|
{
|
||||||
|
ShowSearchError(ex, resetItems: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadPageBatchAsync(string apiKey, string search, int version, int pageCount, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_isLoadingMore)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadRequestId = Interlocked.Increment(ref _loadRequestId);
|
||||||
|
var startPage = _nextPage;
|
||||||
|
var resetItems = startPage == 1;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isLoadingMore = true;
|
||||||
|
IsLoading = true;
|
||||||
|
|
||||||
|
var gifs = new List<KlipyGif>(PageSize * pageCount);
|
||||||
|
var nextPage = startPage;
|
||||||
|
var hasMore = false;
|
||||||
|
|
||||||
|
for (var i = 0; i < pageCount && version == _searchVersion && !cancellationToken.IsCancellationRequested; i++)
|
||||||
|
{
|
||||||
|
var page = startPage + i;
|
||||||
|
var result = await KlipyClient.SearchAsync(apiKey, search, page, PageSize, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gifs.AddRange(result.Gifs);
|
||||||
|
nextPage = page + 1;
|
||||||
|
hasMore = result.HasMore;
|
||||||
|
if (!hasMore)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextPage = nextPage;
|
||||||
|
HasMoreItems = hasMore;
|
||||||
|
|
||||||
|
if (resetItems && gifs.Count == 0)
|
||||||
|
{
|
||||||
|
EmptyContent = new CommandItem(new NoOpCommand())
|
||||||
|
{
|
||||||
|
Title = "No GIF found",
|
||||||
|
Subtitle = $"Try another search for \"{search}\".",
|
||||||
|
Icon = Icon,
|
||||||
|
};
|
||||||
|
ReplaceItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newItems = gifs.Select(CreateItem).ToArray();
|
||||||
|
if (resetItems)
|
||||||
|
{
|
||||||
|
ReplaceItems(newItems);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AppendItems(newItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
if (version == _searchVersion)
|
||||||
|
{
|
||||||
|
ShowSearchError(ex, resetItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (version == _searchVersion)
|
||||||
|
{
|
||||||
|
ShowSearchError(ex, resetItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (loadRequestId == _loadRequestId)
|
||||||
|
{
|
||||||
|
_isLoadingMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version == _searchVersion && loadRequestId == _loadRequestId)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowSearchError(Exception ex, bool resetItems)
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
HasMoreItems = false;
|
||||||
|
EmptyContent = new CommandItem(new NoOpCommand())
|
||||||
|
{
|
||||||
|
Title = "Search failed",
|
||||||
|
Subtitle = ex.Message,
|
||||||
|
Icon = Icon,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resetItems)
|
||||||
|
{
|
||||||
|
ReplaceItems([]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RaiseItemsChanged(DisplayItemCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ListItem CreateItem(KlipyGif gif)
|
||||||
|
{
|
||||||
|
return new ListItem(new CopyGifCommand(gif, _cache))
|
||||||
|
{
|
||||||
|
Title = string.Empty,
|
||||||
|
Subtitle = string.Empty,
|
||||||
|
Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()),
|
||||||
|
MoreCommands = CreateLoadMoreContextItems(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private IContextItem[] CreateLoadMoreContextItems()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateLoadMoreContextItem("Load 30 more GIFs", VirtualKey.L),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandContextItem CreateLoadMoreContextItem(string title, VirtualKey key)
|
||||||
|
{
|
||||||
|
return new CommandContextItem(new LoadMoreGifsCommand(LoadMore))
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Subtitle = "Append the next 30 results without moving the current selection",
|
||||||
|
Icon = Icon,
|
||||||
|
RequestedShortcut = KeyChordHelpers.FromModifiers(true, false, false, false, key, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandItem InitialContent()
|
||||||
|
{
|
||||||
|
return new CommandItem(new NoOpCommand())
|
||||||
|
{
|
||||||
|
Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing",
|
||||||
|
Subtitle = _settingsManager.HasKlipyApiKey
|
||||||
|
? "Type something like thanks, excited, cat, or confused."
|
||||||
|
: "Open Olive settings and set the Klipy API key.",
|
||||||
|
Icon = Icon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandItem LoadingContent(string search)
|
||||||
|
{
|
||||||
|
return new CommandItem(new NoOpCommand())
|
||||||
|
{
|
||||||
|
Title = "Loading GIFs...",
|
||||||
|
Subtitle = $"Searching Klipy for \"{search}\"",
|
||||||
|
Icon = Icon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandItem MissingApiKeyContent()
|
||||||
|
{
|
||||||
|
return new CommandItem(new NoOpCommand())
|
||||||
|
{
|
||||||
|
Title = "Klipy API key missing",
|
||||||
|
Subtitle = "Open Olive settings and paste your Klipy API key.",
|
||||||
|
Icon = Icon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReplaceItems(IListItem[] items)
|
||||||
|
{
|
||||||
|
int count;
|
||||||
|
lock (_itemsLock)
|
||||||
|
{
|
||||||
|
_items.Clear();
|
||||||
|
_items.AddRange(items);
|
||||||
|
_loadedItemCount = items.Length;
|
||||||
|
AddPlaceholderItemsLocked();
|
||||||
|
count = DisplayItemCountLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
RaiseItemsChanged(count);
|
||||||
|
UpdateTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendItems(IListItem[] items)
|
||||||
|
{
|
||||||
|
int count;
|
||||||
|
lock (_itemsLock)
|
||||||
|
{
|
||||||
|
RemovePlaceholderItemsLocked();
|
||||||
|
_items.AddRange(items);
|
||||||
|
_loadedItemCount += items.Length;
|
||||||
|
AddPlaceholderItemsLocked();
|
||||||
|
count = DisplayItemCountLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
RaiseItemsChanged(count);
|
||||||
|
UpdateTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IListItem[] BuildDisplayItemsLocked()
|
||||||
|
{
|
||||||
|
return [.. _items];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddPlaceholderItemsLocked()
|
||||||
|
{
|
||||||
|
if (!HasMoreItems)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < PageSize * LoadMoreBatchPages; i++)
|
||||||
|
{
|
||||||
|
_items.Add(CreateVirtualLoadingItem());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemovePlaceholderItemsLocked()
|
||||||
|
{
|
||||||
|
if (_items.Count > _loadedItemCount)
|
||||||
|
{
|
||||||
|
_items.RemoveRange(_loadedItemCount, _items.Count - _loadedItemCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ListItem CreateVirtualLoadingItem()
|
||||||
|
{
|
||||||
|
return new ListItem(new LoadMoreGifsCommand(LoadMore))
|
||||||
|
{
|
||||||
|
Title = "Loading more GIFs...",
|
||||||
|
Subtitle = "Scroll here or press Ctrl+L to load the next 30",
|
||||||
|
Icon = Icon,
|
||||||
|
MoreCommands = CreateLoadMoreContextItems(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private int DisplayItemCount()
|
||||||
|
{
|
||||||
|
lock (_itemsLock)
|
||||||
|
{
|
||||||
|
return DisplayItemCountLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int DisplayItemCountLocked()
|
||||||
|
{
|
||||||
|
return _items.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaiseItemsChanged(int count)
|
||||||
|
{
|
||||||
|
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaisePropChanged(string propertyName)
|
||||||
|
{
|
||||||
|
PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTitle()
|
||||||
|
{
|
||||||
|
var loadedCount = LoadedItemCount();
|
||||||
|
var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0
|
||||||
|
? "Olive"
|
||||||
|
: HasMoreItems
|
||||||
|
? $"Olive - {loadedCount} GIFs loaded"
|
||||||
|
: $"Olive - {loadedCount} GIFs loaded (all)";
|
||||||
|
|
||||||
|
if (StringComparer.Ordinal.Equals(_title, title))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_title = title;
|
||||||
|
RaisePropChanged(nameof(Title));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int LoadedItemCount()
|
||||||
|
{
|
||||||
|
lock (_itemsLock)
|
||||||
|
{
|
||||||
|
return _loadedItemCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed partial class LoadMoreGifsCommand : InvokableCommand
|
||||||
|
{
|
||||||
|
private readonly Action _loadMore;
|
||||||
|
|
||||||
|
public LoadMoreGifsCommand(Action loadMore)
|
||||||
|
{
|
||||||
|
_loadMore = loadMore;
|
||||||
|
Name = "Load 30 more GIFs";
|
||||||
|
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ICommandResult Invoke()
|
||||||
|
{
|
||||||
|
_loadMore();
|
||||||
|
return CommandResult.KeepOpen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Shmuelie.WinRTServer.CsWinRT;
|
||||||
|
|
||||||
|
namespace Olive;
|
||||||
|
|
||||||
|
public class Program
|
||||||
|
{
|
||||||
|
[MTAThread]
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer")
|
||||||
|
{
|
||||||
|
Shmuelie.WinRTServer.ComServer server = new();
|
||||||
|
|
||||||
|
ManualResetEvent extensionDisposedEvent = new(false);
|
||||||
|
|
||||||
|
// We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
|
||||||
|
// This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object.
|
||||||
|
// If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
|
||||||
|
OliveExtension extensionInstance = new(extensionDisposedEvent);
|
||||||
|
server.RegisterClass<OliveExtension, IExtension>(() => extensionInstance);
|
||||||
|
server.Start();
|
||||||
|
|
||||||
|
// This will make the main thread wait until the event is signalled by the extension class.
|
||||||
|
// Since we have single instance of the extension object, we exit as soon as it is disposed.
|
||||||
|
extensionDisposedEvent.WaitOne();
|
||||||
|
server.Stop();
|
||||||
|
server.UnsafeDispose();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
System.Windows.Forms.MessageBox.Show(
|
||||||
|
"Olive is a PowerToys Command Palette extension.\n\nOpen PowerToys Command Palette, then launch 'Olive GIF Picker' to search and copy GIFs.",
|
||||||
|
"Olive",
|
||||||
|
System.Windows.Forms.MessageBoxButtons.OK,
|
||||||
|
System.Windows.Forms.MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
|
-->
|
||||||
|
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<Platform>ARM64</Platform>
|
||||||
|
<RuntimeIdentifier>win-arm64</RuntimeIdentifier>
|
||||||
|
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<PublishSingleFile>False</PublishSingleFile>
|
||||||
|
<PublishReadyToRun>True</PublishReadyToRun>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
|
-->
|
||||||
|
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<Platform>x64</Platform>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<PublishSingleFile>False</PublishSingleFile>
|
||||||
|
<PublishReadyToRun>True</PublishReadyToRun>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"TemplateCmdPalExtension (Package)": {
|
||||||
|
"commandName": "MsixPackage",
|
||||||
|
"doNotLaunchApp": true
|
||||||
|
},
|
||||||
|
"TemplateCmdPalExtension (Unpackaged)": {
|
||||||
|
"commandName": "Project"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using DataObject = System.Windows.Forms.DataObject;
|
||||||
|
using DataFormats = System.Windows.Forms.DataFormats;
|
||||||
|
using FormsClipboard = System.Windows.Forms.Clipboard;
|
||||||
|
|
||||||
|
namespace Olive.Services;
|
||||||
|
|
||||||
|
internal sealed class ClipboardService
|
||||||
|
{
|
||||||
|
public static async Task CopyGifFileAsync(string gifPath, Uri gifUrl, string title, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(gifPath);
|
||||||
|
ArgumentNullException.ThrowIfNull(gifUrl);
|
||||||
|
|
||||||
|
var fullPath = Path.GetFullPath(gifPath);
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("GIF file not found.", fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var thread = new Thread(() => CopyOnStaThread(fullPath, completion));
|
||||||
|
|
||||||
|
thread.Name = "Olive clipboard STA";
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
|
||||||
|
cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
|
||||||
|
await completion.Task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyOnStaThread(string fullPath, TaskCompletionSource completion)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var gifBytes = File.ReadAllBytes(fullPath);
|
||||||
|
var clipboardHtml = BuildClipboardHtml(gifBytes);
|
||||||
|
|
||||||
|
const int retryCount = 5;
|
||||||
|
for (var attempt = 1; attempt <= retryCount; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = new DataObject();
|
||||||
|
data.SetData("image/gif", autoConvert: false, data: GifStream(gifBytes));
|
||||||
|
data.SetData("GIF", autoConvert: false, data: GifStream(gifBytes));
|
||||||
|
data.SetData(DataFormats.Html, autoConvert: false, data: clipboardHtml);
|
||||||
|
data.SetData("text/html", autoConvert: false, data: clipboardHtml);
|
||||||
|
TryAddBitmapPreview(data, fullPath);
|
||||||
|
FormsClipboard.SetDataObject(data, copy: true, retryTimes: 10, retryDelay: 100);
|
||||||
|
completion.TrySetResult();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (ExternalException) when (attempt < retryCount)
|
||||||
|
{
|
||||||
|
Thread.Sleep(120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion.TrySetException(new InvalidOperationException("The clipboard is temporarily unavailable."));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
completion.TrySetException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MemoryStream GifStream(byte[] gifBytes)
|
||||||
|
{
|
||||||
|
return new MemoryStream(gifBytes, writable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryAddBitmapPreview(DataObject data, string fullPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var image = Image.FromFile(fullPath);
|
||||||
|
data.SetData(DataFormats.Bitmap, autoConvert: true, data: new Bitmap(image));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or ExternalException or OutOfMemoryException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildClipboardHtml(byte[] gifBytes)
|
||||||
|
{
|
||||||
|
var dataUri = "data:image/gif;base64," + Convert.ToBase64String(gifBytes);
|
||||||
|
var fragment = $"<img src=\"{dataUri}\" alt=\"GIF\">";
|
||||||
|
const string markerPrefix = "Version:1.0\r\nStartHTML:{0:0000000000}\r\nEndHTML:{1:0000000000}\r\nStartFragment:{2:0000000000}\r\nEndFragment:{3:0000000000}\r\n";
|
||||||
|
var prefix = string.Format(CultureInfo.InvariantCulture, markerPrefix, 0, 0, 0, 0);
|
||||||
|
const string beforeFragment = "<!DOCTYPE html><html><body><!--StartFragment-->";
|
||||||
|
const string afterFragment = "<!--EndFragment--></body></html>";
|
||||||
|
var html = beforeFragment + fragment + afterFragment;
|
||||||
|
|
||||||
|
var startHtml = Encoding.UTF8.GetByteCount(prefix);
|
||||||
|
var startFragment = startHtml + Encoding.UTF8.GetByteCount(beforeFragment);
|
||||||
|
var endFragment = startFragment + Encoding.UTF8.GetByteCount(fragment);
|
||||||
|
var endHtml = startHtml + Encoding.UTF8.GetByteCount(html);
|
||||||
|
|
||||||
|
var header = string.Format(CultureInfo.InvariantCulture, markerPrefix, startHtml, endHtml, startFragment, endFragment);
|
||||||
|
return header + html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Olive.Klipy;
|
||||||
|
|
||||||
|
namespace Olive.Services;
|
||||||
|
|
||||||
|
internal sealed partial class GifCache
|
||||||
|
{
|
||||||
|
private readonly string _cacheDirectory;
|
||||||
|
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public GifCache()
|
||||||
|
{
|
||||||
|
_cacheDirectory = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"Olive",
|
||||||
|
"Cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetOrDownloadAsync(KlipyGif gif, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_cacheDirectory);
|
||||||
|
|
||||||
|
var fileName = SafeFileName(gif.Id) + ".gif";
|
||||||
|
var finalPath = Path.Combine(_cacheDirectory, fileName);
|
||||||
|
if (File.Exists(finalPath))
|
||||||
|
{
|
||||||
|
return finalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gate = _locks.GetOrAdd(fileName, _ => new SemaphoreSlim(1, 1));
|
||||||
|
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(finalPath))
|
||||||
|
{
|
||||||
|
return finalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempPath = Path.Combine(_cacheDirectory, fileName + "." + Guid.NewGuid().ToString("N") + ".tmp");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await KlipyClient.DownloadGifAsync(gif.GifUrl, tempPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
File.Move(tempPath, finalPath, overwrite: true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tempPath))
|
||||||
|
{
|
||||||
|
File.Delete(tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalPath;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeFileName(string id)
|
||||||
|
{
|
||||||
|
var safe = UnsafeFileNameCharacters().Replace(id, "_");
|
||||||
|
return string.IsNullOrWhiteSpace(safe) ? Guid.NewGuid().ToString("N") : safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("[^a-zA-Z0-9_.-]+")]
|
||||||
|
private static partial Regex UnsafeFileNameCharacters();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="Olive.app"/>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
|
||||||
|
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
|
||||||
|
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Olive
|
||||||
|
|
||||||
|
Olive is a private Microsoft PowerToys Command Palette extension. It searches animated GIFs with Klipy and allow you to quickly the one that suits you best!
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Windows 10/11 with PowerToys and Command Palette enabled.
|
||||||
|
- .NET SDK 10.
|
||||||
|
- Windows SDK `10.0.22621.0` or a compatible newer SDK.
|
||||||
|
- A Klipy API key.
|
||||||
|
|
||||||
|
## Klipy API Key
|
||||||
|
|
||||||
|
Olive stores the Klipy key locally in its Command Palette settings. Do not put the key in source code, Git, environment variables, or the package.
|
||||||
|
|
||||||
|
To configure it, open Command Palette, open the context commands for `Olive GIF Picker`, open Olive settings, paste the key into `Klipy API key`, then save.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build .\Olive.sln -c Debug -p:Platform=x64
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\Build-OlivePackage.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Or double-click:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scripts\Build-OlivePackage.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the shareable package in `dist\OlivePackage`.
|
||||||
|
|
||||||
|
The private signing file is exported to `dist\private\OlivePrivate.pfx`. Do not share it unless the recipient must be able to sign Olive builds.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
From `dist\OlivePackage`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\Install-Olive.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Or double-click:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Install-Olive.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
The script asks for administrator rights, imports `OlivePrivate.cer` into `Cert:\LocalMachine\Root`, then opens the `.msix` with Windows App Installer.
|
||||||
|
|
||||||
|
Restart PowerToys after installation.
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
From `dist\OlivePackage` or `scripts\`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\Uninstall-Olive.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Or double-click:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Uninstall-Olive.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, uninstall removes the Olive package, the Olive certificate from `Cert:\LocalMachine\Root`, local settings, cached GIFs, and MSIX user data.
|
||||||
|
|
||||||
|
Use `-KeepCertificate` to keep the certificate.
|
||||||
|
|
||||||
|
Use `-KeepUserData` to keep local settings and cached GIFs.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Open PowerToys Command Palette.
|
||||||
|
2. Launch `Olive GIF Picker`.
|
||||||
|
3. Search for a GIF.
|
||||||
|
4. Select a result with Enter.
|
||||||
|
5. Paste with Ctrl+V in an app that accepts animated GIF files.
|
||||||
|
|
||||||
|
## Sharing
|
||||||
|
|
||||||
|
- Share only `dist\OlivePackage`.
|
||||||
|
- Do not share `dist\private\OlivePrivate.pfx`.
|
||||||
|
- Do not include the Klipy API key in the package.
|
||||||
|
- Each user must set the Klipy key in Olive settings.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Olive copies GIFs as files to preserve animation.
|
||||||
|
- Some apps may reject file pasting or use fallback text only.
|
||||||
|
- Cached GIFs are stored in `%LOCALAPPDATA%\Olive\Cache`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Build-OlivePackage.ps1" %*
|
||||||
|
set "OLIVE_EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
echo.
|
||||||
|
if "%OLIVE_EXIT_CODE%"=="0" (
|
||||||
|
echo Build completed successfully.
|
||||||
|
) else (
|
||||||
|
echo Build failed with exit code %OLIVE_EXIT_CODE%.
|
||||||
|
)
|
||||||
|
echo Press Enter to close this window.
|
||||||
|
pause >nul
|
||||||
|
exit /b %OLIVE_EXIT_CODE%
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
param(
|
||||||
|
[string]$Configuration = "Release",
|
||||||
|
[string]$Platform = "x64",
|
||||||
|
[string]$CertificateSubject = "CN=OlivePrivate",
|
||||||
|
[string]$CertificatePassword = "Embargo-Boned-Flight6",
|
||||||
|
[switch]$Yes
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptRoot = Split-Path -Parent $PSCommandPath
|
||||||
|
$repoRoot = Split-Path -Parent $scriptRoot
|
||||||
|
$projectPath = Join-Path $repoRoot "Olive\Olive.csproj"
|
||||||
|
$manifestPath = Join-Path $repoRoot "Olive\Package.appxmanifest"
|
||||||
|
$packageRoot = Join-Path $repoRoot "dist\OlivePackage"
|
||||||
|
$privateRoot = Join-Path $repoRoot "dist\private"
|
||||||
|
$certPath = Join-Path $packageRoot "OlivePrivate.cer"
|
||||||
|
$pfxPath = Join-Path $privateRoot "OlivePrivate.pfx"
|
||||||
|
|
||||||
|
function Write-Step([string]$Message) {
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Confirm-Continue {
|
||||||
|
if ($Yes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "This script will:" -ForegroundColor Yellow
|
||||||
|
Write-Host "- create or reuse a CurrentUser code-signing certificate: $CertificateSubject"
|
||||||
|
Write-Host "- export a private PFX to: $pfxPath"
|
||||||
|
Write-Host "- export a public CER to: $certPath"
|
||||||
|
Write-Host "- build and sign the Olive MSIX"
|
||||||
|
Write-Host "- prepare a shareable folder: $packageRoot"
|
||||||
|
$answer = Read-Host "Continue? Type YES"
|
||||||
|
if ($answer -ne "YES") {
|
||||||
|
throw "Cancelled by user."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Confirm-Continue
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $projectPath)) {
|
||||||
|
throw "Project not found: $projectPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $manifestPath)) {
|
||||||
|
throw "Manifest not found: $manifestPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
[xml]$manifest = Get-Content -LiteralPath $manifestPath
|
||||||
|
$packageVersion = $manifest.Package.Identity.Version
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path $privateRoot | Out-Null
|
||||||
|
|
||||||
|
Write-Step "Creating or reusing signing certificate"
|
||||||
|
$cert = Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Subject -eq $CertificateSubject } | Select-Object -First 1
|
||||||
|
if ($null -eq $cert) {
|
||||||
|
$cert = New-SelfSignedCertificate `
|
||||||
|
-Type Custom `
|
||||||
|
-Subject $CertificateSubject `
|
||||||
|
-KeyUsage DigitalSignature `
|
||||||
|
-FriendlyName "Olive private signing" `
|
||||||
|
-CertStoreLocation "Cert:\CurrentUser\My" `
|
||||||
|
-TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3")
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Exporting certificate files"
|
||||||
|
$securePassword = ConvertTo-SecureString $CertificatePassword -AsPlainText -Force
|
||||||
|
Export-Certificate -Cert $cert -FilePath $certPath -Force | Out-Null
|
||||||
|
Export-PfxCertificate -Cert $cert -FilePath $pfxPath -Password $securePassword -Force | Out-Null
|
||||||
|
|
||||||
|
Write-Step "Publishing signed MSIX"
|
||||||
|
dotnet publish $projectPath `
|
||||||
|
-c $Configuration `
|
||||||
|
-p:Platform=$Platform `
|
||||||
|
-p:GenerateAppxPackageOnBuild=true `
|
||||||
|
-p:AppxPackageSigningEnabled=true `
|
||||||
|
-p:PackageCertificateThumbprint=$($cert.Thumbprint)
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "dotnet publish failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Copying MSIX to dist"
|
||||||
|
$publishOutput = Join-Path $repoRoot "Olive\bin\$Platform\$Configuration\net10.0-windows10.0.22621.0\win-$Platform"
|
||||||
|
$expectedMsix = Join-Path $publishOutput "Olive_${packageVersion}_${Platform}.msix"
|
||||||
|
$msix = if (Test-Path -LiteralPath $expectedMsix) {
|
||||||
|
Get-Item -LiteralPath $expectedMsix
|
||||||
|
} else {
|
||||||
|
Get-ChildItem -Path (Join-Path $repoRoot "Olive\AppPackages") -Filter "Olive_${packageVersion}_${Platform}.msix" -Recurse |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $msix) {
|
||||||
|
throw "No MSIX package was generated for version $packageVersion and platform $Platform."
|
||||||
|
}
|
||||||
|
|
||||||
|
Get-ChildItem -Path $packageRoot -Filter "*.msix" | Remove-Item -Force
|
||||||
|
Copy-Item -LiteralPath $msix.FullName -Destination (Join-Path $packageRoot $msix.Name) -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $scriptRoot "Install-Olive.ps1") -Destination (Join-Path $packageRoot "Install-Olive.ps1") -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $scriptRoot "Uninstall-Olive.ps1") -Destination (Join-Path $packageRoot "Uninstall-Olive.ps1") -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $scriptRoot "Install-Olive.cmd") -Destination (Join-Path $packageRoot "Install-Olive.cmd") -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $scriptRoot "Uninstall-Olive.cmd") -Destination (Join-Path $packageRoot "Uninstall-Olive.cmd") -Force
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Package ready:" -ForegroundColor Green
|
||||||
|
Write-Host $packageRoot
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Share this folder with your friends: dist\OlivePackage" -ForegroundColor Green
|
||||||
|
Write-Host "Do not share this private signing file unless you really mean it: $pfxPath" -ForegroundColor Yellow
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
net session >nul 2>&1
|
||||||
|
if not "%ERRORLEVEL%"=="0" (
|
||||||
|
echo Olive install requires administrator rights to trust the MSIX signing certificate.
|
||||||
|
echo Requesting administrator rights...
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%ComSpec%' -ArgumentList '/k ""%~f0"" %*' -Verb RunAs"
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
|
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install-Olive.ps1" %*
|
||||||
|
set "OLIVE_EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
echo.
|
||||||
|
if "%OLIVE_EXIT_CODE%"=="0" (
|
||||||
|
echo Install completed successfully.
|
||||||
|
) else (
|
||||||
|
echo Install failed with exit code %OLIVE_EXIT_CODE%.
|
||||||
|
)
|
||||||
|
echo Press Enter to close this window.
|
||||||
|
pause >nul
|
||||||
|
exit /b %OLIVE_EXIT_CODE%
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
param(
|
||||||
|
[switch]$UseAddAppxPackage,
|
||||||
|
[switch]$Yes
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptRoot = Split-Path -Parent $PSCommandPath
|
||||||
|
$msix = Get-ChildItem -Path $scriptRoot -Filter "*.msix" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
$cert = Get-ChildItem -Path $scriptRoot -Filter "*.cer" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
|
||||||
|
function Test-IsAdministrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Confirm-Continue {
|
||||||
|
if ($Yes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "This script will install Olive for the current Windows user." -ForegroundColor Yellow
|
||||||
|
Write-Host "It will import the public certificate into Cert:\LocalMachine\Root. Administrator rights are required."
|
||||||
|
Write-Host "It will open the MSIX package found next to this script."
|
||||||
|
Write-Host "It will not configure your Klipy API key. You do that later in Olive settings."
|
||||||
|
$answer = Read-Host "Continue? Type YES"
|
||||||
|
if ($answer -ne "YES") {
|
||||||
|
throw "Cancelled by user."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-ForCertificate {
|
||||||
|
param(
|
||||||
|
[string]$Thumbprint,
|
||||||
|
[int]$TimeoutSeconds = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||||
|
do {
|
||||||
|
$root = Get-ChildItem Cert:\LocalMachine\Root | Where-Object { $_.Thumbprint -eq $Thumbprint } | Select-Object -First 1
|
||||||
|
if ($null -ne $root) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
} while ((Get-Date) -lt $deadline)
|
||||||
|
|
||||||
|
throw "Certificate was imported, but Windows did not report it in Cert:\LocalMachine\Root before timeout."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $msix) {
|
||||||
|
throw "No .msix file found next to this script."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $cert) {
|
||||||
|
throw "No .cer certificate found next to this script."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-IsAdministrator)) {
|
||||||
|
throw "Administrator rights are required to import the Olive signing certificate into Cert:\LocalMachine\Root. Run Install-Olive.cmd, or start PowerShell as administrator."
|
||||||
|
}
|
||||||
|
|
||||||
|
Confirm-Continue
|
||||||
|
|
||||||
|
Write-Host "==> Importing certificate into LocalMachine Root" -ForegroundColor Cyan
|
||||||
|
Import-Certificate -FilePath $cert.FullName -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
|
||||||
|
|
||||||
|
$trustedCert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert.FullName)
|
||||||
|
Wait-ForCertificate -Thumbprint $trustedCert.Thumbprint
|
||||||
|
|
||||||
|
if ($UseAddAppxPackage) {
|
||||||
|
Write-Host "==> Installing MSIX with Add-AppxPackage" -ForegroundColor Cyan
|
||||||
|
Add-AppxPackage -Path $msix.FullName
|
||||||
|
} else {
|
||||||
|
Write-Host "==> Opening MSIX with Windows App Installer" -ForegroundColor Cyan
|
||||||
|
Start-Process -FilePath $msix.FullName -Wait
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Olive install flow completed." -ForegroundColor Green
|
||||||
|
Write-Host "Restart PowerToys, open Command Palette, launch 'Klipy GIF Picker', then set the Klipy API key in Olive settings."
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
net session >nul 2>&1
|
||||||
|
if not "%ERRORLEVEL%"=="0" (
|
||||||
|
echo Olive uninstall requires administrator rights to remove the trusted MSIX certificate.
|
||||||
|
echo Requesting administrator rights...
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%ComSpec%' -ArgumentList '/k ""%~f0"" %*' -Verb RunAs"
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
|
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Uninstall-Olive.ps1" %*
|
||||||
|
set "OLIVE_EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
echo.
|
||||||
|
if "%OLIVE_EXIT_CODE%"=="0" (
|
||||||
|
echo Uninstall completed successfully.
|
||||||
|
) else (
|
||||||
|
echo Uninstall failed with exit code %OLIVE_EXIT_CODE%.
|
||||||
|
)
|
||||||
|
echo Press Enter to close this window.
|
||||||
|
pause >nul
|
||||||
|
exit /b %OLIVE_EXIT_CODE%
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
param(
|
||||||
|
[string]$CertificateSubject = "CN=OlivePrivate",
|
||||||
|
[switch]$RemoveCertificate,
|
||||||
|
[switch]$KeepCertificate,
|
||||||
|
[switch]$KeepUserData,
|
||||||
|
[switch]$Yes
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Confirm-Continue {
|
||||||
|
$removeCertificateNow = $RemoveCertificate -or -not $KeepCertificate
|
||||||
|
$removeUserDataNow = -not $KeepUserData
|
||||||
|
|
||||||
|
if ($Yes) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "This script will uninstall Olive for the current Windows user." -ForegroundColor Yellow
|
||||||
|
if ($removeCertificateNow) {
|
||||||
|
Write-Host "It will also remove certificates with subject: $CertificateSubject"
|
||||||
|
} else {
|
||||||
|
Write-Host "It will keep the trusted certificate because -KeepCertificate was used."
|
||||||
|
}
|
||||||
|
if ($removeUserDataNow) {
|
||||||
|
Write-Host "It will also remove Olive local settings and cached GIFs."
|
||||||
|
} else {
|
||||||
|
Write-Host "It will keep Olive local settings and cached GIFs because -KeepUserData was used."
|
||||||
|
}
|
||||||
|
$answer = Read-Host "Continue? Type YES"
|
||||||
|
if ($answer -ne "YES") {
|
||||||
|
throw "Cancelled by user."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-DirectoryIfExists {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "==> Removing directory $Path" -ForegroundColor Cyan
|
||||||
|
Remove-Item -LiteralPath $Path -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsAdministrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-CertificatesBySubject {
|
||||||
|
param(
|
||||||
|
[string]$StorePath,
|
||||||
|
[string]$Subject
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $StorePath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Get-ChildItem $StorePath | Where-Object { $_.Subject -eq $Subject } | ForEach-Object {
|
||||||
|
Write-Host "==> Removing certificate $($_.Thumbprint) from $StorePath" -ForegroundColor Cyan
|
||||||
|
Remove-Item -LiteralPath $_.PSPath -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($RemoveCertificate -or -not $KeepCertificate) -and -not (Test-IsAdministrator)) {
|
||||||
|
throw "Administrator rights are required to remove the Olive signing certificate from Cert:\LocalMachine\Root. Run Uninstall-Olive.cmd, or use -KeepCertificate."
|
||||||
|
}
|
||||||
|
|
||||||
|
Confirm-Continue
|
||||||
|
|
||||||
|
$packages = @(Get-AppxPackage -Name Olive)
|
||||||
|
$packageFamilyNames = @($packages | ForEach-Object { $_.PackageFamilyName } | Sort-Object -Unique)
|
||||||
|
if ($packages.Count -eq 0) {
|
||||||
|
Write-Host "Olive is not installed for the current user." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
foreach ($package in $packages) {
|
||||||
|
Write-Host "==> Removing package $($package.PackageFullName)" -ForegroundColor Cyan
|
||||||
|
Remove-AppxPackage -Package $package.PackageFullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $KeepUserData) {
|
||||||
|
$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
|
||||||
|
$userDataPaths = @(
|
||||||
|
(Join-Path $localAppData "Olive"),
|
||||||
|
(Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Extensions\Olive"),
|
||||||
|
(Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Settings\Olive"),
|
||||||
|
(Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Olive")
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($packageFamilyName in $packageFamilyNames) {
|
||||||
|
$userDataPaths += Join-Path $localAppData "Packages\$packageFamilyName"
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($path in ($userDataPaths | Sort-Object -Unique)) {
|
||||||
|
Remove-DirectoryIfExists -Path $path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($RemoveCertificate -or -not $KeepCertificate) {
|
||||||
|
Remove-CertificatesBySubject -StorePath "Cert:\LocalMachine\Root" -Subject $CertificateSubject
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Olive uninstall completed." -ForegroundColor Green
|
||||||