TunaTuna
ProDocs
ProDocs Download βeta

Building an Extension

This public-beta tutorial builds a complete Projects extension. The supported surface is listed in Extension API.

This tutorial targets Tuna 0.80 and TunaKit 1.14.0.

This tutorial walks you through building a Tuna extension from scratch: a small Projects extension that puts every project folder on your machine a few keystrokes away, and teaches Tuna a new action along the way.

By the end you will have:

  • an extension bundle Tuna loads at launch
  • a catalog of items that show up in search
  • a custom action that works on any file or folder
  • a user-visible setting in Tuna’s preferences

You need Tuna, Xcode 16 or later, and macOS 15 or later. Extensions are written in Swift against TunaKit, which ships as a binary Swift package.

How extensions work

Four concepts do all the work:

  • An Extension is the entry point: a class that declares what the extension provides — its metadata, and its catalogs.
  • A Catalog is a searchable source. Tuna asks it to scan(), indexes whatever it puts in objects, and makes those entities searchable.
  • An ActionCatalog declares verbs in actions. It has no scan lifecycle and is not a user-configurable source.
  • A CatalogItem is one typed, stably identified entity or action.

When Tuna launches it loads your bundle, reads the declaration, instantiates both catalog kinds, and scans the source catalogs. That’s the whole lifecycle; everything below fills in those pieces.

Step 1: Start from the template

Open the Tuna Extension Starter, click Use this template, and clone the repository GitHub creates for you. The starter is an ordinary macOS framework project with the released TunaKit package and the development command already wired.

Rename TemplatePlugin to ProjectsExtension, replace TemplatePluginExtension with ProjectsExtension, and change PRODUCT_BUNDLE_IDENTIFIER to your own reverse-DNS identifier. The starter’s Info.plist principal class should then read:

<key>NSPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ProjectsExtension</string>

That value must match the @objc name you give your extension class in the next step. Everything else in Info.plist stays standard framework boilerplate; extension metadata lives in Swift.

Step 2: The declaration

Create ProjectsExtension.swift:

import Foundation
import TunaKit

@objc(ProjectsExtension)
public final class ProjectsExtension: Extension {
  public override var declaration: ExtensionDeclaration? {
    ExtensionDeclaration(
      metadata: ExtensionMetadata(
        displayName: "Projects",
        author: "Your Name",
        description: "Jump to any project folder.",
        iconName: "folder.badge.gearshape"
      ),
      compatibility: ExtensionDeclarationCompatibility(
        minTuna: "0.80",
        minTunaKit: "1.14.0"
      ),
      catalogs: [
        CatalogDeclaration(
          id: "projects",
          type: ProjectsCatalog.self,
          name: "Projects",
          enabledByDefault: true
        )
      ],
      actionCatalogs: [
        ActionCatalogDeclaration(
          id: "projects.actions",
          type: ProjectsActionsCatalog.self,
          name: "Projects Actions"
        )
      ]
    )
  }
}

A few things worth noticing:

  • The @objc(ProjectsExtension) attribute pins the runtime class name that NSPrincipalClass looks up.
  • The declaration is code, not a plist. Catalog classes are referenced as metatypes (ProjectsCatalog.self), so a typo is a compile error instead of a silently missing catalog.
  • Catalog ids are permanent public API. They end up in user preferences, hotkeys, and tuna:// URLs as <catalog-id>/<item-id> references. Use short kebab-case ids and never rename one casually — the display name is the thing you’re free to change.
  • iconName is an SF Symbol. An invalid name falls back to a generic puzzle piece.
  • compatibility declares the oldest Tuna and TunaKit versions that can safely load the extension. Keep both floors explicit, raise them when you adopt a newer host or TunaKit API, and test the oldest versions you claim to support.
  • There’s deliberately no category field or other store-facing taxonomy here — store categorization is curated on the store side.

Step 3: The catalog

Create ProjectsCatalog.swift:

import Foundation
import TunaKit

final class ProjectsCatalog: Catalog {
  let identifier: String
  let name: String
  private(set) var objects: [CatalogItem] = []

  required init(definition: CatalogDefinition) {
    identifier = definition.identifier
    name = definition.name
  }

  func scan() async {
    let root = ("~/Developer" as NSString).expandingTildeInPath
    let contents = (try? FileManager.default.contentsOfDirectory(
      at: URL(fileURLWithPath: root),
      includingPropertiesForKeys: [.isDirectoryKey],
      options: [.skipsHiddenFiles])) ?? []

    objects = contents
      .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true }
      .map { url in
        let entity = CatalogEntity(
          id: url.path,
          title: url.lastPathComponent,
          path: url.path)
        entity.typeID = .directory
        return entity
      }

    reportScanFinished()
  }
}

The contract:

  • Catalog is a @MainActor protocol with five requirements: identifier, name, objects, init(definition:), and scan(). Tuna instantiates the catalog through init(definition:) — take your identity from the definition rather than hard-coding it.
  • scan() is async and gets called on load, on demand, and when a watcher requests a rescan. Populate objects, then call reportScanFinished() so Tuna re-indexes immediately.
  • Item ids must be stable. The id is the item’s identity across preferences, ranking, hotkeys, and aliases. A path is a fine id for a file or folder; a title is not — users rename things.
  • entity.typeID = .directory types the item. Types are what connect entities to actions: because these items are folders, every directory action Tuna already has (Open, Reveal in Finder, Move…, and anything other extensions add) now works on your projects for free. Without it the item defaults to the generic .entity type and only universal actions apply.
  • CatalogEntity with a path gets file icons and previews automatically.

Step 4: Run it

Tuna auto-loads extensions from ~/Library/Application Support/Tuna/ExtensionsDev, so “running” your extension means: build the framework, copy it there, and ask Tuna to reload. The starter’s project-local command does the build and install:

./scripts/tuna-extension install --restart

Two loading rules matter:

  • A rescan re-reads catalog data. If you changed extension code, restart Tuna—macOS cannot unload and replace framework code in a running process. That is what --restart is for.
  • Extensions fail soft: a bundle that throws during load is logged and skipped, and Tuna looks healthy without it. If your items don’t appear, check Settings → Extensions to confirm the extension loaded, and Settings → Sources for your catalog’s row.

Now summon Tuna and type a project name. Your folders are there, and pressing ⏎ opens them like any other file.

Step 5: A custom action

Entities make things findable; actions make Tuna do things. Let’s add “Open in Terminal”, available on any file or folder in Tuna — not just your own items. Create ProjectsActionsCatalog.swift:

import AppKit
import TunaKit

final class ProjectsActionsCatalog: ActionCatalog {
  let identifier: String
  let name: String

  private(set) lazy var actions: [CatalogAction] = [openInTerminal]

  required init(definition: ActionCatalogDefinition) {
    identifier = definition.identifier
    name = definition.name
  }

  private lazy var openInTerminal: CatalogAction = {
    let action = CatalogAction(
      id: "open-in-terminal",
      title: "Open in Terminal"
    ) { subject, _ in
      guard let entity = subject as? CatalogEntity, let path = entity.path else {
        return .failure("No path for \(subject.title)")
      }
      let url = URL(fileURLWithPath: path)
      NSWorkspace.shared.open(
        [url],
        withApplicationAt: URL(
          fileURLWithPath: "/System/Applications/Utilities/Terminal.app"),
        configuration: NSWorkspace.OpenConfiguration())
      return .success
    }
    action.systemSymbolName = "terminal"
    action.supportedSubjectTypes = [.file, .directory]
    return action
  }()
}

How actions plug in:

  • The callback receives a subject (what the user selected) and an optional target (a second object, for actions like “Move to…”). It returns an ActionResult.success, .failure(String?), or richer results like .results([CatalogItem]) for actions that produce output to browse.
  • supportedSubjectTypes = [.file, .directory] is the matching rule: Tuna offers this action whenever the selected subject is a file or folder, whichever catalog it came from. This is the same mechanism that made the built-in directory actions work on your project folders in step 3 — types are the contract between catalogs and actions.
  • Action ids follow the same stability rule as entities: users bind hotkeys and defaults to projects.actions/open-in-terminal, so the id is forever; the title is presentation.
  • For actions that need a target (the “Move to somewhere” pattern), set targetRequirement = .required and constrain the picker with allowedTargetTypes.

Run ./scripts/tuna-extension install --restart (binary change!), select any file, and tab into the action list: Open in Terminal is there.

Step 6: A setting

Hard-coding ~/Developer is rude. Declare a setting on the catalog — settings render automatically in the catalog’s options in Settings → Sources:

CatalogDeclaration(
  id: "projects",
  type: ProjectsCatalog.self,
  name: "Projects",
  enabledByDefault: true,
  settings: [
    CatalogSettingDefinition(
      key: "ProjectsRoot",
      type: .string,
      label: "Projects folder",
      defaultValue: "~/Developer",
      description: "Folder whose subfolders appear as projects."
    )
  ]
)

Then read it in scan():

let store = CatalogSettingStore(catalogIdentifier: identifier)
let root = (store.stringValue(for: "ProjectsRoot", defaultValue: "~/Developer")
  as NSString).expandingTildeInPath

Setting types are .string, .bool, and .secret — secrets (API tokens and the like) are stored in the Keychain, not in plain config. When the user changes a setting, Tuna rescans the catalog, so scan() reading the store each time is the whole synchronization story.

What you built

ProjectsExtension/
├── ProjectsExtension.swift    # Extension subclass + declaration
├── ProjectsCatalog.swift      # entities and setting read
├── ProjectsActionsCatalog.swift # one declared action
└── Info.plist                 # NSPrincipalClass, bundle boilerplate

An extension is exactly this: a declaration, source catalogs that produce typed, stably identified entities, and action catalogs that declare the verbs.

Where to go next

  • Real examples — every first-party store extension lives in tunaformac/TunaExtensions, from the ~200-line CleanShot extension to GitHub’s API-backed catalogs with secret settings and connection checks.
  • Richer catalogs — browsing into items (CatalogHierarchyNode). Prefer BrowseCatalogItem, DeferredBrowseCatalogItem, or ScopedSearchBrowseCatalogItem for generic browse roots so Tuna automatically makes Browse their default action. Richer catalogs can also add provider-backed search (ScopedCatalogSearchProviding), file-watching (FileSystemCatalog), and app enrichments that attach your catalog to another app’s browse view (appBrowseEnrichments in the declaration).
  • Shipping — packaging your extension as a .tunaextension and submitting it to the store: see Extension Distribution.
  • Themes — a separate, simpler surface: a bundle with a TKTheme Info.plist manifest and a window class. No declaration needed.