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

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