chore: first version

This commit is contained in:
JNIH
2026-07-30 18:39:38 +02:00
commit c953ca8460
54 changed files with 4197 additions and 0 deletions
+48
View File
@@ -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
![alt](https://example.com/img.png?--x-cmdpal-fit=fit&--x-cmdpal-maxwidth=400)
```
### 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];
}
```
+149
View File
@@ -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)
+66
View File
@@ -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 13 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 57 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 |
+13
View File
@@ -0,0 +1,13 @@
bin/
obj/
.vs/
dist/
Olive/AppPackages/
*.user
*.suo
*.log
*.tmp
*.pfx
*.cer
*.msix
*.msixbundle
+15
View File
@@ -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/
+1
View File
@@ -0,0 +1 @@
Olive
+4
View File
@@ -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>
+8
View File
@@ -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>
+7
View File
@@ -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>
+6
View File
@@ -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>
+11
View File
@@ -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>
+18
View File
@@ -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>
+43
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+1
View File
@@ -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
+51
View File
@@ -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();
}
}
+7
View File
@@ -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;
+52
View File
@@ -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();
}
}
+103
View File
@@ -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;
}
}
+41
View File
@@ -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;
+96
View File
@@ -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>
+31
View File
@@ -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,
},
];
}
}
+28
View File
@@ -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();
}
+77
View File
@@ -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>
+555
View File
@@ -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();
}
}
}
+39
View File
@@ -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>
+11
View File
@@ -0,0 +1,11 @@
{
"profiles": {
"TemplateCmdPalExtension (Package)": {
"commandName": "MsixPackage",
"doNotLaunchApp": true
},
"TemplateCmdPalExtension (Unpackaged)": {
"commandName": "Project"
}
}
}
+107
View File
@@ -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;
}
}
+70
View File
@@ -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();
}
+14
View File
@@ -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>
+97
View File
@@ -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`.
+12
View File
@@ -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>
+14
View File
@@ -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%
+114
View File
@@ -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
+22
View File
@@ -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%
+83
View File
@@ -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."
+22
View File
@@ -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%
+110
View File
@@ -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