commit c953ca8460ae8283b26cb796c49e3289ec072c6d Author: JNIH Date: Thu Jul 30 18:39:38 2026 +0200 chore: first version diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..462ec27 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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) diff --git a/.github/instructions/cmdpal-extension.instructions.md b/.github/instructions/cmdpal-extension.instructions.md new file mode 100644 index 0000000..af351c3 --- /dev/null +++ b/.github/instructions/cmdpal-extension.instructions.md @@ -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(() => extensionInstance); + server.Start(); + extensionDisposedEvent.WaitOne(); + server.Stop(); + server.UnsafeDispose(); + } + } +} +``` + +### Package.appxmanifest + +Two critical extension registrations must be present: + +1. **COM server** — `com:ComServer` with matching CLSID and `-RegisterProcessAsComServer` args +2. **App extension** — `uap3:AppExtension` with `Name="com.microsoft.commandpalette"` and `CreateInstance ClassId` matching the GUID + +The CLSID must be identical in three places: the `[Guid]` attribute, the `com:Class Id`, and the `CreateInstance ClassId`. + +## Page Types + +### ListPage (Most Common) + +Displays a searchable list of items: + +```csharp +internal sealed partial class MyPage : ListPage +{ + public MyPage() + { + Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + Title = "My page"; + Name = "Open"; + } + + public override IListItem[] GetItems() => [ + new ListItem(new OpenUrlCommand("https://example.com")) { Title = "Example" }, + ]; +} +``` + +### DynamicListPage (Search-Reactive) + +Responds to search text changes for filtering or live queries: + +```csharp +internal sealed partial class MyDynamicPage : DynamicListPage +{ + private IListItem[] _filteredItems = []; + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + _filteredItems = _allItems + .Where(i => i.Title.Contains(newSearch, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + RaiseItemsChanged(); + } + + public override IListItem[] GetItems() => _filteredItems; +} +``` + +- Supports `Filters` property for category filtering +- Call `RaiseItemsChanged()` after updating items to notify the UI + +### ContentPage (Rich Content) + +Displays rich content like markdown, forms, or images: + +```csharp +internal sealed partial class MyContentPage : ContentPage +{ + public override IContent[] GetContent() => [ + new MarkdownContent("# Hello\nThis is **markdown**."), + ]; +} +``` + +- Can return multiple `IContent` items (mix markdown, forms, images, etc.) +- Supports `Commands` property for context menu items via `CommandContextItem` + +## Content Types + +| Type | Description | +|------|-------------| +| `MarkdownContent(string)` | Renders markdown with headers, links, code blocks, tables, images | +| `FormContent` | Adaptive Cards forms with `TemplateJson`, optional `DataJson`, and `SubmitForm()` | +| `PlainTextContent(string)` | Plain text; optional `FontFamily.Monospace` and `WrapWords` | +| `ImageContent` | Images with `MaxWidth`/`MaxHeight` constraints | +| `TreeContent` | Hierarchical nested content; override `GetChildren()` for child `IContent[]` | + +### MarkdownContent Images + +Supports `file:`, `data:` (base64), and `https:` URLs. Image hints control rendering: + +```markdown +![alt](https://example.com/img.png?--x-cmdpal-fit=fit&--x-cmdpal-maxwidth=400) +``` + +### FormContent (Adaptive Cards) + +```csharp +internal sealed partial class MyForm : FormContent +{ + public MyForm() + { + TemplateJson = """{ "type": "AdaptiveCard", ... }"""; + DataJson = """{ "name": "default" }"""; + } + + public override CommandResult SubmitForm(string payload) + { + var data = JsonSerializer.Deserialize(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 | diff --git a/.github/skills/add-adaptive-card-form/SKILL.md b/.github/skills/add-adaptive-card-form/SKILL.md new file mode 100644 index 0000000..d4a6c94 --- /dev/null +++ b/.github/skills/add-adaptive-card-form/SKILL.md @@ -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/) diff --git a/.github/skills/add-adaptive-card-form/references/form-patterns.md b/.github/skills/add-adaptive-card-form/references/form-patterns.md new file mode 100644 index 0000000..f4a347a --- /dev/null +++ b/.github/skills/add-adaptive-card-form/references/form-patterns.md @@ -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 _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]; +} +``` diff --git a/.github/skills/add-dock-band/SKILL.md b/.github/skills/add-dock-band/SKILL.md new file mode 100644 index 0000000..2ccb90d --- /dev/null +++ b/.github/skills/add-dock-band/SKILL.md @@ -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) diff --git a/.github/skills/add-extension-settings/SKILL.md b/.github/skills/add-extension-settings/SKILL.md new file mode 100644 index 0000000..da23573 --- /dev/null +++ b/.github/skills/add-extension-settings/SKILL.md @@ -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("maxResults"), out var val) ? val : 10; + + public bool ShowSubtitles => + _settings.GetSetting("showSubtitles"); + + public string SortOrder => + _settings.GetSetting("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(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) diff --git a/.github/skills/add-fallback-commands/SKILL.md b/.github/skills/add-fallback-commands/SKILL.md new file mode 100644 index 0000000..00e9571 --- /dev/null +++ b/.github/skills/add-fallback-commands/SKILL.md @@ -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 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) diff --git a/.github/skills/publish-extension/SKILL.md b/.github/skills/publish-extension/SKILL.md new file mode 100644 index 0000000..21cf1e7 --- /dev/null +++ b/.github/skills/publish-extension/SKILL.md @@ -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 diff --git a/.github/skills/publish-extension/references/store-publishing.md b/.github/skills/publish-extension/references/store-publishing.md new file mode 100644 index 0000000..c6841aa --- /dev/null +++ b/.github/skills/publish-extension/references/store-publishing.md @@ -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 + +``` + +And update the publisher display name: + +```xml + + Your Extension Name + YOUR_PUBLISHER_DISPLAY_NAME_HERE + + +``` + +### Update `.csproj` + +Add or update the following properties in your `.csproj` file: + +```xml + + YOUR_PACKAGE_IDENTITY_NAME_HERE + YOUR_PACKAGE_IDENTITY_PUBLISHER_HERE + 0.0.1.0 + +``` + +### Update Image Assets ItemGroup + +Ensure all image assets are included in the package by updating the `ItemGroup`: + +```xml + + + +``` + +> **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\\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. diff --git a/.github/skills/publish-extension/references/winget-publishing.md b/.github/skills/publish-extension/references/winget-publishing.md new file mode 100644 index 0000000..b66edce --- /dev/null +++ b/.github/skills/publish-extension/references/winget-publishing.md @@ -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 `` property and add unpackaged mode: + +```xml + + + + + + None + +``` + +### 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 "" "" +``` + +`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 `None` 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 "" "" ` + --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 ` 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 | diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d746ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +bin/ +obj/ +.vs/ +dist/ +Olive/AppPackages/ +*.user +*.suo +*.log +*.tmp +*.pfx +*.cer +*.msix +*.msixbundle diff --git a/.idea/.idea.Olive/.idea/.gitignore b/.idea/.idea.Olive/.idea/.gitignore new file mode 100644 index 0000000..1560554 --- /dev/null +++ b/.idea/.idea.Olive/.idea/.gitignore @@ -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/ diff --git a/.idea/.idea.Olive/.idea/.name b/.idea/.idea.Olive/.idea/.name new file mode 100644 index 0000000..93f85a0 --- /dev/null +++ b/.idea/.idea.Olive/.idea/.name @@ -0,0 +1 @@ +Olive \ No newline at end of file diff --git a/.idea/.idea.Olive/.idea/encodings.xml b/.idea/.idea.Olive/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.Olive/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.Olive/.idea/indexLayout.xml b/.idea/.idea.Olive/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.Olive/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.Olive/.idea/inspectionProfiles/Project_Default.xml b/.idea/.idea.Olive/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..de81209 --- /dev/null +++ b/.idea/.idea.Olive/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,39 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.Olive/.idea/misc.xml b/.idea/.idea.Olive/.idea/misc.xml new file mode 100644 index 0000000..30bab2a --- /dev/null +++ b/.idea/.idea.Olive/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.Olive/.idea/vcs.xml b/.idea/.idea.Olive/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.Olive/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..f467770 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + x64;ARM64 + true + Recommended + <_SkipUpgradeNetAnalyzersNuGetWarning>true + direct + $(Platform) + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..d06bf66 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,18 @@ + + + true + + + + + + + + + + + + + + + diff --git a/Olive.sln b/Olive.sln new file mode 100644 index 0000000..43450d0 --- /dev/null +++ b/Olive.sln @@ -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 diff --git a/Olive/Assets/AppLogo150.scale-200.png b/Olive/Assets/AppLogo150.scale-200.png new file mode 100644 index 0000000..82e99e0 Binary files /dev/null and b/Olive/Assets/AppLogo150.scale-200.png differ diff --git a/Olive/Assets/AppLogo44.scale-200.png b/Olive/Assets/AppLogo44.scale-200.png new file mode 100644 index 0000000..6e32e94 Binary files /dev/null and b/Olive/Assets/AppLogo44.scale-200.png differ diff --git a/Olive/Assets/AppLogo44.targetsize-24_altform-unplated.png b/Olive/Assets/AppLogo44.targetsize-24_altform-unplated.png new file mode 100644 index 0000000..c46e91c Binary files /dev/null and b/Olive/Assets/AppLogo44.targetsize-24_altform-unplated.png differ diff --git a/Olive/Assets/SplashScreen.scale-200.png b/Olive/Assets/SplashScreen.scale-200.png new file mode 100644 index 0000000..2f91f49 Binary files /dev/null and b/Olive/Assets/SplashScreen.scale-200.png differ diff --git a/Olive/Assets/StoreLogo.png b/Olive/Assets/StoreLogo.png new file mode 100644 index 0000000..762b79c Binary files /dev/null and b/Olive/Assets/StoreLogo.png differ diff --git a/Olive/Assets/Wide310x150Logo.scale-200.png b/Olive/Assets/Wide310x150Logo.scale-200.png new file mode 100644 index 0000000..db09bdf Binary files /dev/null and b/Olive/Assets/Wide310x150Logo.scale-200.png differ diff --git a/Olive/Assets/olive.png b/Olive/Assets/olive.png new file mode 100644 index 0000000..4d7e0b0 Binary files /dev/null and b/Olive/Assets/olive.png differ diff --git a/Olive/BundleArtifacts/x64.txt b/Olive/BundleArtifacts/x64.txt new file mode 100644 index 0000000..1d1db6f --- /dev/null +++ b/Olive/BundleArtifacts/x64.txt @@ -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 diff --git a/Olive/Commands/CopyGifCommand.cs b/Olive/Commands/CopyGifCommand.cs new file mode 100644 index 0000000..c98a1b0 --- /dev/null +++ b/Olive/Commands/CopyGifCommand.cs @@ -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(); + } +} diff --git a/Olive/GlobalUsings.cs b/Olive/GlobalUsings.cs new file mode 100644 index 0000000..9b170da --- /dev/null +++ b/Olive/GlobalUsings.cs @@ -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; diff --git a/Olive/Helpers/SettingsManager.cs b/Olive/Helpers/SettingsManager.cs new file mode 100644 index 0000000..495cdf3 --- /dev/null +++ b/Olive/Helpers/SettingsManager.cs @@ -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(); + } +} diff --git a/Olive/Klipy/KlipyClient.cs b/Olive/Klipy/KlipyClient.cs new file mode 100644 index 0000000..f3a764c --- /dev/null +++ b/Olive/Klipy/KlipyClient.cs @@ -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 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 + { + ["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 ConvertResults(KlipyItem[]? items) + { + if (items is null || items.Length == 0) + { + return []; + } + + var gifs = new List(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; + } +} diff --git a/Olive/Klipy/KlipyModels.cs b/Olive/Klipy/KlipyModels.cs new file mode 100644 index 0000000..c93ff04 --- /dev/null +++ b/Olive/Klipy/KlipyModels.cs @@ -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 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; diff --git a/Olive/Olive.csproj b/Olive/Olive.csproj new file mode 100644 index 0000000..4afa0d7 --- /dev/null +++ b/Olive/Olive.csproj @@ -0,0 +1,96 @@ + + + WinExe + Olive + app.manifest + + 10.0.26100.68-preview + net10.0-windows10.0.22621.0 + 10.0.19041.0 + 10.0.19041.0 + win-x64;win-arm64 + + win-$(Platform).pubxml + true + enable + true + $(NoWarn);APPX1707 + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + true + + + + true + true + + true + 2 + + IL2081;$(WarningsNotAsErrors) + + + true + + + + + + false + + true + true + true + + + + + false + + + false + + + + diff --git a/Olive/OliveCommandsProvider.cs b/Olive/OliveCommandsProvider.cs new file mode 100644 index 0000000..9c2efc4 --- /dev/null +++ b/Olive/OliveCommandsProvider.cs @@ -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, + }, + ]; + } +} diff --git a/Olive/OliveExtension.cs b/Olive/OliveExtension.cs new file mode 100644 index 0000000..d0cda7a --- /dev/null +++ b/Olive/OliveExtension.cs @@ -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(); +} diff --git a/Olive/Package.appxmanifest b/Olive/Package.appxmanifest new file mode 100644 index 0000000..48db482 --- /dev/null +++ b/Olive/Package.appxmanifest @@ -0,0 +1,77 @@ + + + + + + + Olive + Private + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Olive/Pages/GifPickerPage.cs b/Olive/Pages/GifPickerPage.cs new file mode 100644 index 0000000..4b2ab07 --- /dev/null +++ b/Olive/Pages/GifPickerPage.cs @@ -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 _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? ItemsChanged; + + public event TypedEventHandler? 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(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(); + } + } + +} diff --git a/Olive/Program.cs b/Olive/Program.cs new file mode 100644 index 0000000..3620396 --- /dev/null +++ b/Olive/Program.cs @@ -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(() => 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); + } + } +} diff --git a/Olive/Properties/PublishProfiles/win-arm64.pubxml b/Olive/Properties/PublishProfiles/win-arm64.pubxml new file mode 100644 index 0000000..7969006 --- /dev/null +++ b/Olive/Properties/PublishProfiles/win-arm64.pubxml @@ -0,0 +1,15 @@ + + + + + FileSystem + ARM64 + win-arm64 + bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ + true + False + True + + diff --git a/Olive/Properties/PublishProfiles/win-x64.pubxml b/Olive/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..c53b588 --- /dev/null +++ b/Olive/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,15 @@ + + + + + FileSystem + x64 + win-x64 + bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ + true + False + True + + diff --git a/Olive/Properties/launchSettings.json b/Olive/Properties/launchSettings.json new file mode 100644 index 0000000..9e90cf7 --- /dev/null +++ b/Olive/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "TemplateCmdPalExtension (Package)": { + "commandName": "MsixPackage", + "doNotLaunchApp": true + }, + "TemplateCmdPalExtension (Unpackaged)": { + "commandName": "Project" + } + } +} diff --git a/Olive/Services/ClipboardService.cs b/Olive/Services/ClipboardService.cs new file mode 100644 index 0000000..0da40a5 --- /dev/null +++ b/Olive/Services/ClipboardService.cs @@ -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 = $"\"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 = ""; + const string afterFragment = ""; + 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; + } +} diff --git a/Olive/Services/GifCache.cs b/Olive/Services/GifCache.cs new file mode 100644 index 0000000..baaa94d --- /dev/null +++ b/Olive/Services/GifCache.cs @@ -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 _locks = new(StringComparer.OrdinalIgnoreCase); + + public GifCache() + { + _cacheDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Olive", + "Cache"); + } + + public async Task 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(); +} diff --git a/Olive/app.manifest b/Olive/app.manifest new file mode 100644 index 0000000..4c1ed0c --- /dev/null +++ b/Olive/app.manifest @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d14e745 --- /dev/null +++ b/README.md @@ -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`. diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..e6a17ff --- /dev/null +++ b/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/scripts/Build-OlivePackage.cmd b/scripts/Build-OlivePackage.cmd new file mode 100644 index 0000000..520a90e --- /dev/null +++ b/scripts/Build-OlivePackage.cmd @@ -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% diff --git a/scripts/Build-OlivePackage.ps1 b/scripts/Build-OlivePackage.ps1 new file mode 100644 index 0000000..a5ca7ea --- /dev/null +++ b/scripts/Build-OlivePackage.ps1 @@ -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 diff --git a/scripts/Install-Olive.cmd b/scripts/Install-Olive.cmd new file mode 100644 index 0000000..63bb767 --- /dev/null +++ b/scripts/Install-Olive.cmd @@ -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% diff --git a/scripts/Install-Olive.ps1 b/scripts/Install-Olive.ps1 new file mode 100644 index 0000000..b95f230 --- /dev/null +++ b/scripts/Install-Olive.ps1 @@ -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." diff --git a/scripts/Uninstall-Olive.cmd b/scripts/Uninstall-Olive.cmd new file mode 100644 index 0000000..3cb9d73 --- /dev/null +++ b/scripts/Uninstall-Olive.cmd @@ -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% diff --git a/scripts/Uninstall-Olive.ps1 b/scripts/Uninstall-Olive.ps1 new file mode 100644 index 0000000..33a989c --- /dev/null +++ b/scripts/Uninstall-Olive.ps1 @@ -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