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 inobjects, 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 thatNSPrincipalClasslooks 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, andtuna://URLs as<catalog-id>/<item-id>references. Use short kebab-case ids and never rename one casually — the displaynameis the thing you’re free to change. iconNameis an SF Symbol. An invalid name falls back to a generic puzzle piece.compatibilitydeclares 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:
Catalogis a@MainActorprotocol with five requirements:identifier,name,objects,init(definition:), andscan(). Tuna instantiates the catalog throughinit(definition:)— take your identity from the definition rather than hard-coding it.scan()isasyncand gets called on load, on demand, and when a watcher requests a rescan. Populateobjects, then callreportScanFinished()so Tuna re-indexes immediately.- Item ids must be stable. The
idis 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 = .directorytypes 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.entitytype and only universal actions apply.CatalogEntitywith apathgets 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
--restartis 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 optionaltarget(a second object, for actions like “Move to…”). It returns anActionResult—.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 toprojects.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 = .requiredand constrain the picker withallowedTargetTypes.
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). PreferBrowseCatalogItem,DeferredBrowseCatalogItem, orScopedSearchBrowseCatalogItemfor 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 (appBrowseEnrichmentsin the declaration). - Shipping — packaging your extension as a
.tunaextensionand submitting it to the store: see Extension Distribution. - Themes — a separate, simpler surface: a bundle with a
TKThemeInfo.plist manifest and a window class. No declaration needed.
