Skip to content

Latest commit

 

History

History
227 lines (175 loc) · 8.34 KB

File metadata and controls

227 lines (175 loc) · 8.34 KB

ADB Extension for Command Palette — Claude Guide

Documentation

Build & Deploy

Build via Visual Studio or:

dotnet build AdbExtension.sln

Deploy the MSIX package, then reload Command Palette to pick up changes.

Project Conventions

  • New commands → AdbExtension/Commands/, extend InvokableCommand
  • New pages → AdbExtension/Pages/, extend ListPage or DynamicListPage
  • All ADB execution goes through AdbHelper.RunAdb() — never use Process directly in command files
  • Error toasts use CommandResult.KeepOpen() so the user can read them; success toasts use the default Dismiss()
  • Icons: new IconInfo("https://github.com/favicon.ico") per project preference

CommandPalette Toolkit — Quick Reference

Navigation

Page extends Command (implements ICommand). Pass a page anywhere a command is accepted — the palette navigates into it automatically. No string-based registration needed.

// Top-level entry point
new CommandItem(new MyPage()) { Title = "My Command" }

// List item that navigates into a sub-page
new ListItem(new MySubPage(arg)) { Title = "Go deeper" }

// List item that runs a command
new ListItem(new MyInvokableCommand()) { Title = "Do thing" }

Base Classes

Class Use when
InvokableCommand Action with no UI — runs and returns a CommandResult
ListPage Static list of items
DynamicListPage List that reacts to search input — override UpdateSearchText()
CommandProvider Extension entry point — returns top-level ICommandItem[]

Page Activation Hook (On-Load Refresh)

The framework calls FetchItemsGetItems() before subscribing to ItemsChanged. This means any RaiseItemsChanged fired from the constructor is lost and the page shows empty on first open.

The fix: re-implement INotifyItemsChanged on the page class and intercept the add accessor. The framework subscribes via INotifyItemsChanged.ItemsChanged +=, so our accessor fires right after subscription — triggering a second FetchItems while the framework is already listening.

Critical: use INotifyItemsChanged.ItemsChanged, not IListPage.ItemsChangedItemsChanged lives on INotifyItemsChanged, and the class must re-list the interface to override base class dispatch.

// For pages that load data async (e.g. AdbExtensionPage):
internal sealed partial class MyPage : DynamicListPage, INotifyItemsChanged
{
    private event TypedEventHandler<object, IItemsChangedEventArgs>? _itemsChanged;

    event TypedEventHandler<object, IItemsChangedEventArgs> INotifyItemsChanged.ItemsChanged
    {
        add { _itemsChanged += value; RefreshData(); } // called every time user navigates to this page
        remove => _itemsChanged -= value;
    }

    protected new void RaiseItemsChanged(int totalItems = -1)
        => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems));
}

// For pages with synchronous GetItems() (e.g. PackageActionsPage):
internal sealed partial class MyPage : ListPage, INotifyItemsChanged
{
    private event TypedEventHandler<object, IItemsChangedEventArgs>? _itemsChanged;

    event TypedEventHandler<object, IItemsChangedEventArgs> INotifyItemsChanged.ItemsChanged
    {
        add { _itemsChanged += value; _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); } // fire immediately after subscribe
        remove => _itemsChanged -= value;
    }

    protected new void RaiseItemsChanged(int totalItems = -1)
        => _itemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems));
}

Do not call IsLoading = true + Task.Run(Load) from the constructor — the event fires before the framework subscribes and the signal is lost.

DynamicListPage Pattern

internal sealed partial class MyPage : DynamicListPage
{
    public MyPage()
    {
        PlaceholderText = "Type to filter...";
    }

    public override void UpdateSearchText(string oldSearch, string newSearch)
        => RaiseItemsChanged(0); // triggers GetItems() re-call

    public override IListItem[] GetItems()
    {
        // Filter using this.SearchText
    }
}

CommandResult Options

CommandResult.ShowToast("message")              // show toast, dismiss palette
CommandResult.ShowToast(new ToastArgs {
    Message = "message",
    Result = CommandResult.KeepOpen()           // show toast, keep palette open
})
CommandResult.KeepOpen()                        // do nothing, stay on current page
CommandResult.Dismiss()                         // close the palette
CommandResult.GoBack()                          // navigate to previous page
CommandResult.GoHome()                          // navigate to root
CommandResult.Confirm(new ConfirmationArgs())   // show confirmation dialog

ListItem Properties

new ListItem(command)
{
    Title = "Required",
    Subtitle = "Optional secondary text",
    Icon = new IconInfo("https://...") ,        // URL icon
    Section = "Section header",                 // groups items under a header
}

Icon Options

new IconInfo("https://example.com/icon.png")   // URL (light+dark same)
new IconInfo(lightIconData, darkIconData)       // separate light/dark
IconHelpers.FromRelativePath("Assets\\foo.png") // bundled asset

InvokableCommand Pattern

internal sealed partial class MyCommand : InvokableCommand
{
    public MyCommand()
    {
        Name = "Do Thing";
        Icon = new IconInfo("https://...");
    }

    public override ICommandResult Invoke()
    {
        try
        {
            // do work
            return CommandResult.ShowToast("Done");
        }
        catch (Exception ex) when (ex is Win32Exception w && w.NativeErrorCode == 2)
        {
            return ErrorToast("ADB not found. Make sure adb.exe is in your PATH.");
        }
        catch (Exception ex)
        {
            return ErrorToast($"Unexpected error: {ex.Message}");
        }
    }

    private static ICommandResult ErrorToast(string message) =>
        CommandResult.ShowToast(new ToastArgs { Message = message, Result = CommandResult.KeepOpen() });
}

AdbHelper API

// Run any adb command. Read both streams before WaitForExit (prevents pipe deadlocks).
AdbHelper.RunAdb(string arguments, out string stdout, out string stderr);

// Returns 3rd-party packages. Debuggable ones sorted first.
// Returns [] on error (no device, adb not in PATH, etc.)
PackageInfo[] AdbHelper.GetInstalledPackages();

// PackageInfo record
record PackageInfo(string Name, bool IsDebuggable);

Releasing a New Version

Step 1 — Bump version in all 5 files

File Field
AdbExtension/AdbExtension.csproj <AppxPackageVersion>
AdbExtension/Package.appxmanifest Identity Version=
AdbExtension/app.manifest assemblyIdentity version=
AdbExtension/build-exe.ps1 $Version default param
AdbExtension/setup-template.iss #define AppVersion

One-liner (replace OLD and NEW):

$files = @("AdbExtension/AdbExtension.csproj","AdbExtension/Package.appxmanifest","AdbExtension/app.manifest","AdbExtension/build-exe.ps1","AdbExtension/setup-template.iss")
$files | ForEach-Object { (Get-Content $_) -replace 'OLD','NEW' | Set-Content $_ }

Step 2 — Trigger the GitHub Actions build

gh workflow run release-msix.yml --ref master -f release_notes="Your release notes here"

This reads the version from the csproj automatically, builds x64 + ARM64, bundles into a .msixbundle, signs it, and creates a GitHub Release.

Step 3 — Submit to Partner Center

  1. Download the .msixbundle from the GitHub Release
  2. Partner Center → app → new submission → Packages → upload the .msixbundle
  3. Update description/notes, submit