DroppyKit 1.21.0
Build Droplets for Droppy.
The whole DroppyKit guide in one document, from adding the package to submitting a Droplet, with the full API index at the end.
19 chapters · 172 public types · getdroppy.app/docs/droppykit · 2026-09-09
Opens your browser’s print dialog. Choose Save as PDF as the destination.
Start here
DroppyKit overview
Build Droplets for Droppy with SwiftUI.
A Droplet is an extension that lives inside Droppy. It draws on the same surfaces Droppy's own features do — the notch, the shelf, the lock screen — and it is written in SwiftUI, against this SDK.
Droplets are native code running in Droppy's process, so the Store is review-and-sign: you build against DroppyKit, open a merge request on the Store repository (gitlab.com/droppyformac1/droplets, one folder per droplet), and the merged commit is built, signed and published by its pipeline. No droplet ships without someone having read it. By default, Droppy runs an unsigned one, such as the droplet you are building, after the person whose Mac it is approves that exact build, and asks them again each time Droppy opens. Don't ask again for local droplets turns off these questions for all local builds, including new and changed ones. Turn Ask before running local droplets back on in Settings, Store, Local droplets to restore the questions. Bundle validity checks still apply.
The surfaces you can build
| Surface | What it is | Protocol |
|---|---|---|
| Shelf widget | A card on the expanded shelf, alone or sharing the row | ShelfWidgetProviding |
| Live activity | A compact row beside the notch, plus its expanded detail | LiveActivityProviding |
| Expanded surface | A takeover on the notch, for something that needs the room | ExpandedSurfaceProviding |
| Settings pane | Your droplet's own page inside Droppy's Settings | SettingsPaneProviding |
| HUD | A transient message on the notch | HUDPresenting |
| Lock screen row | One line on Droppy's lock surface | LockScreenStatusProviding |
| Menu bar extra | A tile in Droppy's Command Center, the panel under its menu bar icon | MenuBarExtraProviding |
| URLs | droppy://droplet/<id>/…, the reply channel for an app you drive | DropletURLHandling |
| Tools for AI apps | Actions Claude, Cursor or Codex can run through Droppy's MCP server | MCPToolProviding |
You pick the ones you need. A droplet that only draws a shelf widget is a perfectly good droplet.
What a droplet is made of
- A Swift package that builds a dynamic library and links DroppyKit.
- A
droplet.jsonmanifest — seeDropletManifest— naming the droplet, the surfaces it provides, the capabilities it needs, and who made it. - An Icon Composer document named in
icon. Jordy designs it for every Store droplet; your package carries a placeholder. See The icon. - A creator avatar, also required, which is the round mark beside your droplet's name in the Store. See
DropletCreatorAvatar.
Trying it without installing anything
DroppyKitHarness opens your droplet in Droppy's Settings panel — the real one, built from the app's own source — with a page per surface, a switch per capability, and a running list of everything your droplet asks the host to do. It never touches the system, so it runs happily alongside Droppy itself.
import DroppyKit
import DroppyKitHarness
@main
struct WorldClockHarness: DropletHarnessApp {
static func makeDroplet() -> any Droplet { WorldClockDroplet() }
}Building it with a coding agent
Most droplets will be written with Claude Code, Codex or Cursor at the keyboard, and the SDK is built for that. Every scaffold carries the brief an agent reads before it starts, and the DroppyKit MCP server hands it the whole loop as tools: the build, the checks, pictures of every surface it can look at, a JSON verdict, and an install into Droppy Playground that says whether the bundle loaded. See Build with a coding agent.
Start here
Building a droplet
- The manifest
- Design guidelines
- Good citizen
- Shelf widgets
- Live activities
- Expanded surfaces
- Settings panes
- Launchers
- Tools for AI apps
- RepositoryUpdates
- Host support
Artwork
Testing
Publishing
Entry point
DropletDropletPrincipalDropletIDDropletHostDropletURLHandlingDropletURLRequest
Manifest
Host services
DropletPreferencesServiceDropletEnvironmentServiceDropletHUDServiceDropletShelfServiceDropletLiveActivityServiceDropletNotchSurfaceServiceDropletShortcutsServiceDropletWorkspaceServiceDropletFeedbackServiceDropletPermissionsServiceDropletInstallStateServiceDropletLogServiceDropletLauncherService
Design system
DroppySpacingDroppyRadiusDroppyOpacityAdaptiveColorsDroppyAnimationDroppyTransitionDroppyLiveActivityMetricsDroppyWashCircleButtonStyleDroppyWashPillButtonStyleDroppyWashToneDropletSettingsPaneDropletSettingsSectionDropletSettingsCardDropletControlRowDropletToggleRowDropletStackedRowDropletSliderRow
Versioning
Start here
Add the package
Add DroppyKit to your droplet with Swift Package Manager.
A droplet is a Swift package that builds a dynamic library and links DroppyKit. Droppy loads the built bundle into its own process, so the library type matters: a static library has nothing for the loader to open.
Requirements
The droppykit tool
droppykit new, run, build, validate, submit, docs, agent, mcp, version and update live in the SDK repository's Scripts folder, not in the package your droplet depends on: SwiftPM checks that out for the compiler, not for you. Clone the repository once and put the folder on your PATH.
git clone https://gitlab.com/droppyformac1/droppykit.git
export PATH="$PWD/droppykit/Scripts:$PATH"
droppykitAdd the export line to your shell profile to keep it. Running droppykit with no command prints the list above.
droppykit version # which SDK this is, which one the PATH finds, what a droplet pins
droppykit update # pull the newest SDK; inside a droplet, move its pin too
droppykit update --raise # and require the new SDK: Package.swift from: and kit.minAPITwo SDK versions are in play for every droplet: the checkout whose scripts build the bundle, and the tag the droplet's Package.swift pins, which is what its compile and its harness use. They drift apart when the checkout is pulled and the pin is not; droppykit update run inside the droplet moves both, and droppykit version shows both.
droppykit update does not raise what the droplet declares it needs, from: in Package.swift and kit.minAPI in droplet.json: a droplet that uses nothing new keeps loading in older Droppys. When it starts using something the new release added, droppykit update --raise requires that release in both. droppykit build checks the other direction: it reads which DroppyKit API the built droplet calls and fails when kit.minAPI is older than the newest of them, naming those calls and the version to declare. Declared too low, a droplet passes the handshake on an older Droppy and then fails to load there with "Symbol not found".
In Package.swift
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "WorldClock",
platforms: [.macOS(.v14)],
products: [
.library(name: "WorldClock", type: .dynamic, targets: ["WorldClock"])
],
dependencies: [
.package(url: "https://gitlab.com/droppyformac1/droppykit.git", from: "1.6.0")
],
targets: [
.target(
name: "WorldClock",
dependencies: [.product(name: "DroppyKit", package: "droppykit")]
),
.executableTarget(
name: "WorldClockHarness",
dependencies: [
"WorldClock",
.product(name: "DroppyKitHarness", package: "droppykit")
]
)
]
)The second target is the development harness. It is not shipped in your droplet — see The harness.
In Xcode
File ▸ Add Package Dependencies, then paste https://gitlab.com/droppyformac1/droppykit.git. Add DroppyKit to your droplet target and DroppyKitHarness to the harness target.
Do not build with a bare swift build
Important. Always build the shipping bundle with Scripts/build-droplet.sh.
A shipped droplet must contain its own code and not a copy of DroppyKit. Droppy already has DroppyKit as a resilient framework; a droplet carrying a second copy gives every shared type two metadata records, and the cast the loader makes between Droppy's Droplet and yours fails with no useful diagnostic. A plain swift build produces exactly that second copy, because SwiftPM folds the dependency in statically.
droppykit build does the link itself: it builds DroppyKit resiliently, wraps it in a framework whose install name is the one Droppy ships, and links your target's object file against that. The bundle then carries an undefined reference Droppy resolves at load.
It also compiles with -enable-library-evolution. Without it a droplet emits direct-access imports the resilient framework does not export, and the bundle passes every structural check, loads in the harness, and dies inside Droppy at dyld with Symbol not found.
Scripts/validate-droplet.sh refuses a bundle carrying either wrong shape, so a mistake here is caught before submission rather than after install.
Checking the link
# 0 in a correct bundle, non-zero in a broken one.
nm -u WorldClock.droplet/Contents/MacOS/WorldClock | grep -c '9DroppyKit.*vau'
# Must name the framework, and must not name libDroppyKit.
otool -L WorldClock.droplet/Contents/MacOS/WorldClock | grep DroppyKitStart here
Your first droplet
Scaffold a droplet, run it in the harness, and see it on the shelf.
This walks through a droplet that shows the time in another timezone on Droppy's shelf. It is the shortest thing that is still a real droplet: an entry point, a surface, and a manifest.
The finished version ships with the SDK as Examples/WorldClock, including its Icon Composer document and its harness target.
1. Scaffold
droppykit is the SDK repository's Scripts/droppykit, on your PATH as Add the package sets up.
droppykit new worldclock --name "World Clock"
cd worldclockdroppykit new writes the package, a placeholder Icon Composer document (leave it: Jordy designs every Store droplet's icon, see The icon), a placeholder creator avatar, droplet.json, a CHANGELOG.md and a README.md to fill in, and the harness target. The avatar and the words are placeholders that build and validate; the command's output lists what to replace before you submit. If you would rather start from Examples/WorldClock, copy that directory instead.
It also writes AGENTS.md, CLAUDE.md and the MCP wiring for Claude Code, Codex and Cursor. If a coding agent is going to write most of this droplet, open the folder in it now and let it read the brief; see Build with a coding agent. The rest of this walkthrough is the same either way.
2. The entry point
Every droplet has two types. The principal is what Droppy's loader instantiates through NSPrincipalClass; it is @objc and does nothing but make the droplet. The droplet is the object that lives for the session.
import DroppyKit
import SwiftUI
@objc(WorldClockPrincipal)
public final class WorldClockPrincipal: NSObject, DropletPrincipal {
public override init() { super.init() }
@MainActor public func makeDroplet() -> AnyObject { WorldClockDroplet() }
}
@MainActor
public final class WorldClockDroplet: NSObject, ObservableObject, Droplet {
public nonisolated static let id: DropletID = "worldclock"
private var host: DropletHost?
@Published private var now = Date()
private var ticker: AnyCancellable?
public func activate(host: DropletHost) throws {
self.host = host
ticker = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] date in self?.now = date }
}
public func deactivate() {
ticker?.cancel()
ticker = nil
host = nil
}
}Important. Everything activate(host:) starts must be torn down in deactivate(). Swift cannot unload code, so a droplet that leaves a timer running keeps running until Droppy relaunches.
3. A surface
Conform to ShelfWidgetProviding, declare the widget, and build its view.
extension WorldClockDroplet: ShelfWidgetProviding {
public var widgetDescriptors: [ShelfWidgetDescriptor] {
[
ShelfWidgetDescriptor(
id: "clocks",
title: "World Clock",
systemImage: "globe",
layoutTraits: ShelfWidgetLayoutTraits(
preferredSoloWidth: 420,
preferredPairedWidth: 210,
contentHeight: .fixed(150)
)
)
]
}
public func makeWidgetView(_ id: ShelfWidgetID, context: ShelfWidgetContext) -> AnyView {
AnyView(WorldClockWidget(droplet: self, context: context))
}
public func makeWidgetSettingsPopover(_ id: ShelfWidgetID) -> AnyView? { nil }
}Both widths are required. Droppy refuses a descriptor that leaves either to a host fallback, because the shelf's layout is one algorithm over supplied numbers and a missing number has no correct default.
4. The manifest
droplet.json describes the droplet to everything that is not the droplet: the build script, the validator, the Store, and getdroppy.app.
{
"id": "worldclock",
"name": "World Clock",
"version": "1.0.0",
"summary": "Every timezone you care about, on the shelf",
"category": "Productivity",
"kit": { "abi": 1, "minAPI": "1.0.0" },
"minAppVersion": "15.3.0",
"capabilities": ["shelf-read"],
"surfaces": ["shelf-widget"],
"icon": "WorldClock.icon",
"creator": {
"name": "Jane Doe",
"kind": "community",
"url": "https://jane.example",
"avatar": "Creator.png"
},
"source": {
"repository": "https://gitlab.com/droppyformac1/droplets/-/tree/main/droplets/worldclock",
"license": "MIT"
}
}surfaces and your conformances must agree. The harness reports the difference, because the two disagreeing is the most common reason a droplet validates and then does nothing.
5. Run it
droppykit runThe harness opens on your droplet's Overview page. Switch to Shelf widgets to see it solo and paired, side by side.
6. Build and validate
droppykit build # produces WorldClock.droplet
droppykit validate # the same checks the Store repository runs
droppykit submit # fork the Store repository, push a branch, open the merge request7. Submit
The Store is a repository, gitlab.com/droppyformac1/droplets, one folder per droplet. droppykit submit opens the merge request that adds yours; when it merges, the pipeline builds, signs and publishes it, and every Mac running Droppy can install it within minutes. Replace the scaffold's placeholders first, all but the icon, and read Good citizen for what review checks beyond the build. See Submitting.
Start here
Build with a coding agent
Let Claude Code, Codex or Cursor write, build, test and install your droplet, with pictures of every surface and a verdict it can read.
Most droplets will be written with a coding agent at the keyboard, and an agent has two problems a person does not. It cannot see the harness window, so it cannot tell whether the widget it just wrote looks right. And it does not know what a droplet is until something tells it, so left alone it reaches for a bare swift build, forgets a width, or lists a surface it never implemented.
DroppyKit answers both. Every scaffold carries a brief the agent reads before it starts: what a droplet is, the loop it has to run after every change, the rules that are not negotiable, and where these guides and the SDK sources are on disk. The harness renders every surface to a PNG and writes a JSON verdict, so the agent can look at what it built and read whether the manifest, the conformances and the activation agree, without anybody opening a window. And an MCP server hands the whole loop to the agent as tools, the pictures included.
One command
droppykit new writes the agent files with the rest of the scaffold. For a droplet that already exists, or after moving one:
cd worldclock
droppykit agentThat writes five files into the package:
| File | Read by | What it is |
|---|---|---|
AGENTS.md | Codex, Cursor, and most agents | The brief. Yours to edit once written; only rewritten with --force. |
CLAUDE.md | Claude Code | Imports AGENTS.md, so there is one brief, not two. |
.cursor/rules/droppykit.mdc | Cursor | The same brief as a rule that applies to every file. |
.mcp.json | Claude Code | The DroppyKit MCP server. |
.cursor/mcp.json | Cursor | The same server. |
The two MCP files carry the absolute path of your SDK checkout and of the package, on purpose: the agent then finds the server whether or not droppykit is on the PATH of the shell that launched it. Run droppykit agent again after moving either, and once on every Mac the droplet is cloned to.
Connecting the agent
Claude Code. Open the package folder and run claude. It reads CLAUDE.md and, the first time, asks you to approve the droppykit server from .mcp.json. To have the server in every project instead of per package:
claude mcp add --scope user droppykit -- /path/to/droppykit/Scripts/droppykit mcpCodex. Codex reads AGENTS.md on its own, and registers MCP servers per user rather than per folder, once:
codex mcp add droppykit -- /path/to/droppykit/Scripts/droppykit mcpStarted that way the server finds the droplet from the folder Codex is running in, so one registration serves every droplet on the Mac.
Cursor. Open the package folder. The rule applies to every file, and the server is in .cursor/mcp.json; if Cursor asks, enable it under Settings, MCP.
Anything else that speaks MCP. droppykit mcp --package <dir> is a stdio server, standard library Python on the interpreter Xcode's command line tools install, so it runs the moment the SDK is cloned and needs nothing built or installed.
The tools
| Tool | What it does |
|---|---|
droppykit_doctor | Xcode, Swift, the SDK version, whether Droppy Playground is installed, the package's manifest, dependency and built bundle. Run first. |
droppykit_new | Scaffold a droplet, agent files included. |
droppykit_build | droppykit build: the bundle Droppy can load, universal, linkage asserted. |
droppykit_validate | droppykit validate: the checks the Store repository runs. |
droppykit_shots | Every harness page as a picture, returned inline, plus the JSON verdict. |
droppykit_install | Copy the bundle into Droppy Playground, relaunch it, and report whether it loaded, with the loader's log lines. |
droppykit_manifest | A static check of droplet.json against the sources, no build: id agreement, surfaces against conformances, versions, artwork. |
droppykit_docs | These guides as text, and a search across them and the SDK sources. |
droppykit_host_log | The droplet loader's log from Droppy and the Playground. |
droppykit_open_harness | Open the harness window for you to look at. |
droppykit_submit | What droppykit submit would do for the checkout: the merge request description and the commands, without running them. |
Every tool shells out to the same script a person runs, so an agent and a developer get the same answers.
What the agent sees
droppykit_shots is the one that matters. It runs the harness headless, with --shots and --report, and returns the pictures of the surfaces the droplet declares, downsampled to the page's width in points so a tool result stays small, with the verdict first: every surface as provided, missing or undeclared, whether activate(host:) threw, which host calls were refused and why. The images are the real views, laid out and drawn by the same code Droppy uses. See The harness for what the report carries.
An agent that reads the report and looks at the pictures after every change catches the two failures that otherwise reach a submission: a droplet that validates and then does nothing because its manifest and its conformances disagree, and a widget that compiles and looks wrong.
The loop the brief asks for
The brief tells the agent that a green build is not the end, and that every change goes through, in order: build, validate, shots and report, then an install into Droppy Playground that says the bundle loaded. It also carries the rules that trip an agent working from general knowledge:
- Surfaces in
droplet.jsonand protocol conformances must agree. - Every shelf widget declares both
preferredSoloWidthandpreferredPairedWidth. - Everything
activate(host:)starts,deactivate()stops. - Host calls are gated by
capabilities; declare what you use, and only that. - Never a bare
swift buildfor the bundle. - Dark surfaces, Droppy's tokens, no outlines, no gradients, sentence case.
- Never make the droplet's icon. Jordy designs every Store droplet's icon, so the agent leaves the scaffold's placeholder alone and writes an idea for the icon into the merge request's Icon concept section instead.
- The settings pane rules and Good citizen, in short.
- Every placeholder the scaffold left that is the developer's to replace before
droppykit submit: the creator avatar, the summary, the changelog and the README.
The full text is Scripts/agent/AGENTS.md.tmpl in the SDK repository, and AGENTS.md in every scaffold.
Without MCP
The same loop is four shell commands, and the brief lists them, so an agent with a shell and no MCP still has the whole thing:
droppykit build
droppykit validate
droppykit run -- --shots ./shots --report ./shots/report.json
cp -R .build/WorldClock.droplet \
~/Library/Application\ Support/Droppy\ Playground/Droplets/worldclock/An agent that can read image files reads the PNGs in ./shots; the report is plain JSON.
Working with it
- Say what you want on which surface. "A shelf widget that shows my next three calendar events, solo and paired" gives the agent a surface and both compositions; "make a calendar droplet" leaves it guessing.
- Ask for the shots by name when you want to see them yourself: they are in
.build/shots/after everydroppykit_shots, anddroppykit runopens the live window. - Ask the agent to install into the Playground and to say whether it loaded. "Loaded" is a real answer from the running host, not an inference from a build.
- Try it in Droppy itself once the Playground run looks right: Settings, Store, Local droplets takes the bundle or its folder. By default, Droppy asks before it runs that build, then again each time it opens. Don't ask again for local droplets turns off those questions for all local builds; Ask before running local droplets in the same Settings section restores them.
- Keep
AGENTS.mdyours. Add the things your droplet needs the agent to know: an API it talks to, a design decision, a surface you do not want. It is only rewritten when you pass--force.
When something is off
- The agent does not list the droppykit tools. Run
droppykit agentin the package (the MCP files carry absolute paths and go stale when either the package or the SDK moves), then start a new agent session; MCP servers are read at launch. - No
report.jsonafter the shots. The harness the droplet built against is older than DroppyKit 1.2.0 and ignored--report. Set the dependency inPackage.swifttofrom: "1.6.0"and build again. - The shots are empty or missing. Read the verdict: an
activate(host:)that throws, or a harness target that does not build, produces no pictures. droppykit buildstops with "no compiled objects for DroppyKit". The SDK checkout is older than 1.2.1 on a toolchain whose SwiftPM uses the native build engine (Xcode 26's shipping Swift 6.2 and 6.3).droppykit update, then build again.- The Playground says not loaded. The Store row's subtitle in the Playground is the loader's verdict. The usual causes are a
minAppVersionnewer than the Droppy the Playground stands in for, akit.minAPInewer than the DroppyKit it embeds, an id that disagrees betweendroplet.json,Droplet.idand the folder, or a bundle built with a bareswift build.droppykit_host_logreads the same lines.
Start here
The manifest
The lifecycle, the host, and the capabilities a droplet runs under.
A droplet is created once per Droppy launch and activated when it is installed, enabled, and not removed. It runs in-process, on the main actor, and shares fate with the app.
That is why the Store is review-and-sign rather than open loading: a droplet has Droppy's permissions, so a droplet is read before it is published.
Lifecycle
activate(host:) is called once, after the host's own managers are up. Store the host — there is no global accessor, and if you did not keep it you cannot reach it. Keep activation fast; the host runs it under a watchdog.
Throwing marks the droplet failed for the session. The user sees it as unavailable on its Store page, with the error.
deactivate() is called when the user disables or removes the droplet. Tear down everything: timers, observers, windows, registered shortcuts. After it returns the droplet must be inert.
prepareForRemoval() is a veto. Return false to cancel a removal that would leave something in a bad state — a transfer in flight, an unsaved document. It defaults to true.
The host
DropletHost is the whole surface. Every service is present whether or not you may use it; the effect is gated. A call you lack the capability for returns false or nil and is logged, so a missing grant never crashes.
func activate(host: DropletHost) throws {
self.host = host
if host.isGranted(.globalShortcuts) {
host.shortcuts.register(id: "toggle", title: "Toggle World Clock", defaultShortcut: nil) {
// …
}
}
}Check isGranted(_:) to hide an affordance you cannot honour, not as a substitute for handling refusal — capabilities can be revoked while you run.
Capabilities
Declare what you need in droplet.json. The user sees the list before installing, so ask for what you use and nothing more. A long list is the single most common reason a submission is sent back.
| Capability | Grants |
|---|---|
hud | Presenting transient HUDs on the notch |
shelf-read | Observing the shelf's state |
shelf-write | Opening and closing the shelf |
expanded-surface | Presenting takeover surfaces |
global-shortcuts | Registering shortcuts the user can rebind |
clipboard-read / clipboard-write | Reading and writing the pasteboard |
network-client | Outbound network requests |
menu-bar | A menu bar extra |
lock-screen | A row on the lock surface |
The full list is DropletPermission.
Storage
DropletPreferencesService namespaces every key to droplet.<your-id>.<key>, so two droplets can both store "interval" without colliding and neither can read the other's. That namespacing is why DropletID forbids . — without the rule, droplet a writing b.c and droplet a.b writing c are the same key.
Settings survive disable, re-enable, update, and removal. A user who removes and reinstalls finds their configuration intact.
For files, containerDirectory is a private per-droplet directory that persists across updates.
Logging
Use DropletLogService, never print. A droplet runs inside an installed app, and print from there goes nowhere a user or a reviewer can find.
host.log.info("activated for \(zone)")Build a surface
Design guidelines
How to make a droplet look like part of Droppy rather than a guest in it.
Droppy's surfaces are dark, dense, and small. A droplet that ignores that reads as a foreign window bolted onto the notch, and that is the most common reason a submission is sent back for design rather than for code.
Everything below is available as a token or a component. Use them: a hardcoded number is a number that stops matching the next time Droppy's styling moves, whereas a token follows it.
Surfaces are dark
Notch and shelf surfaces are dark chrome, always. Foreground colours come from AdaptiveColors:
Text(title).foregroundStyle(AdaptiveColors.notchSurfacePrimaryText)
Text(detail).foregroundStyle(AdaptiveColors.notchSurfaceSecondaryText)
Text(hint).foregroundStyle(AdaptiveColors.notchSurfaceTertiaryText)Do not put your widget in a card. The shelf's black is the background: almost every one of Droppy's own widgets lays its content directly on the shell, with no fill and no border around it, and a droplet should do the same. Droppy paints nothing behind a widget either, so there is no card to match; a fill or an outline around the whole widget is exactly what makes a droplet look like it came from somewhere else. notchSurfaceCardFill is for a surface inside the widget that has to read as raised, a tile or a chip, never a frame around it.
Lay the widget out like Droppy's
Droppy's own widgets share one layout, and a droplet that follows it reads as one of them. The rules, in the order they are usually broken:
- Fill the rectangle. The root view takes the full width and height the shelf hands it:
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading). Content that floats in the middle of an otherwise empty rectangle, or hugs one corner of it, is the surest sign of a widget that was not built for the shelf. - One padding, and the host gives you the number.
.padding(context.contentInsets)at the root, once, and nothing on top of it. The host clips a widget at a rounded card corner, andcontentInsetsis the clearance for it: 8 on every edge alone under a notch, where the corner is 18pt (a square glyph needs 5.25 to clear it). A solo card filling the Dynamic Island takes 0: no card corner clips it, the island shell does, so every widget on it sits flush to its rectangle and the island keeps one even gap around all of them. A widget in a grouped row takes 0 on every edge too, on either shape (1.21.0): the host clips a grouped card square so its rows land on the row's lines beside Droppy's own (next rule). A widget wrapped around the camera (below) takes 0 on every edge: the host clips a wrapped card square, so no corner arc can shave a control, and its row starts at the card's top edge with its controls on the card's sides, where Droppy's own wrapped rows sit. Artwork that fills a wrapped card rounds itself, as on a solo island card. The shelf's chrome already owns the rest: it keeps every card 17.5pt from the shell's walls and floor (16 plus the 1.5pt slot inset), clears the cutout, and opens the 32pt shoulders. A widget that pads again, or adds a corner inset of its own, doubles the gap. Droppy's own widgets draw flush and clear the 18pt corner element by element, so there a droplet sits 8pt in from the edge where a built-in row sits flush; the one number is what keeps every droplet's corner content whole. - Artwork that is the widget is the one exception, and it is not a card. The no-card rule (Surfaces are dark, above) still stands: do not put your widget in a card, and give its root no background, fill, outline or rounded box. The exception is only for artwork that IS the widget's content, a picture filling the whole tile, like the keyboard a keyboard-cleaning widget shows or the condition sky Droppy's own Weather card is painted with. That picture shows the tile's edge, so the content over it pads
mdlon every edge in place ofcontentInsets, and the picture goes after the padding (.padding(DroppySpacing.mdl).frame(maxWidth: .infinity, maxHeight: .infinity).background { artwork }) so it still fills the tile to its rounded edge. On a solo island card no card corner clips it, so round it there yourself atshelfFullBleedCornerRadius, as the Weather card does. A fill, a gradient or a rounded box drawn to hold ordinary content is a card, which that rule forbids, not this exception. - No unused space. A rectangle taller than its content is empty shelf the user looks at. Declare the height the content needs through
ShelfWidgetContentHeightinstead of leaving air, and useSpacer(minLength: 0)only to push a bottom row to the bottom, never to fill a height you did not need. - The declared height includes the insets. Since DroppyKit 1.20.1 (Droppy 16.0.1, Playground 1.0.27)
contentInsetsis 8 on every edge of a card alone under the notch, where it used to be 0, so a.fixed(150)widget has 134 points for its content there (from 1.21.0 a grouped row and a row wrapped around the camera get 0, clipped square). A widget sized exactly to its content before 1.20.1 now clips its last row: declare the content's height plus the insets. Pad withcontext.contentInsetsand never with a number of your own: the inset differs by slot and by host (0 for a solo card on the island; 0 under the notch and 12 alone on the island before Droppy 16.0.1), so any fixed padding is wrong somewhere. - Leading text, trailing numbers. Titles and labels sit at the leading edge; a value beside a label sits at the trailing edge with
.monospacedDigit()so it does not jitter. A row is anHStackthat spans the full width, with aSpacer(minLength: DroppySpacing.md)between the two, never two texts with a fixed gap. - The header is a row. A 12pt medium symbol and a 12pt semibold title in
notchSurfaceSecondaryText,xsmapart, thensmdown to the content. A control that acts on the whole widget sits at the trailing end of that row. On a Mac with a notch that row is the one that wraps around the camera. - Rhythm on the 4pt grid.
DroppySpacingsteps between rows and nothing else; no 5, no 13. - Solo and paired are compositions. The paired rectangle has room for one number, not for a shorter list. Branch on
context.isCompact, see Shelf widgets. - A row shares its lines. Two to four widgets in a row share one top line, one bottom line and one gap to the divider (1.21.0). Your first row starts on the rectangle's top edge, your last row ends on its bottom edge (17.5pt over the shell's floor), and nothing pads either. A row is as tall as its tallest widget, so yours may get more height than it declared: the header stays on top, the last row (a control row, a legend, the figures) goes to the bottom, and one
Spacer(minLength: 0)between them takes the rest. Never centre the composition in the leftover, and never leave the leftover under your last row: either way your rows stop lining up with the widget beside you. A control row at either end is 28pt, Droppy's paired wash, so its centre lines up with the control row beside it. An empty state (a message and its one action) puts the action on the bottom line too.
The World Clock example is that layout, wrapped around the camera where the host wraps it (next section) and classic everywhere else:
var body: some View {
VStack(alignment: .leading, spacing: DroppySpacing.sm) {
if let wrap = context.notchWrap {
// Beside the camera: the title in the left band, the control in
// the right one, nothing in between.
HStack(spacing: 0) {
title.frame(maxWidth: wrap.bandWidth, alignment: .leading)
Spacer(minLength: 0)
secondsButton(diameter: 28)
.frame(maxWidth: wrap.bandWidth, alignment: .trailing)
}
.frame(height: wrap.headerHeight)
} else {
HStack(spacing: DroppySpacing.xsm) {
title
Spacer(minLength: 0)
if !context.isCompact { secondsButton(diameter: 34) }
}
}
ForEach(clocks) { clock in
HStack {
Text(clock.label)
.foregroundStyle(AdaptiveColors.notchSurfaceSecondaryText)
Spacer(minLength: DroppySpacing.md)
Text(time(clock))
.monospacedDigit()
.foregroundStyle(AdaptiveColors.notchSurfacePrimaryText)
}
}
Spacer(minLength: 0)
}
.padding(context.contentInsets)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var title: some View {
HStack(spacing: DroppySpacing.xsm) {
Image(systemName: "globe").font(.system(size: 12, weight: .medium))
Text("World Clock").font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(AdaptiveColors.notchSurfaceSecondaryText)
}
private func secondsButton(diameter: CGFloat) -> some View {
Button { showsSeconds.toggle() } label: { Image(systemName: "clock") }
.buttonStyle(DroppyWashCircleButtonStyle(
diameter: diameter,
usesAdaptiveForegrounds: context.usesAdaptiveForegrounds
))
}Wrap the row around the camera where it fits
On a MacBook with a notch, Droppy's own widgets do not leave the two bands beside the camera empty. Notes, Pomodoro, Stay Awake, the Calendar and System Stats lift their top row into them, one half left of the notch and one half right of it, and start everything else under the camera, so the open shelf is shorter by about the row's height. A droplet's widget does the same wherever its row fits (DroppyKit 1.21.0):
- Opt in with the traits. Declare the classic header row's height, inside
contentInsets, and the narrowest band its halves still fit in:ShelfWidgetLayoutTraits(..., notchWrap: ShelfWidgetNotchWrapTraits(headerHeight: 34, minimumBandWidth: 64)). - Read the context, never the Mac.
notchWrapis set only when the host really wrapped the mount: the widget is alone on the shelf of a Mac with a notch, at the Regular size, and the bands are at least as wide as you asked. Everywhere else (a grouped row, the Dynamic Island, a display without a notch, a Droppy older than 1.21.0) it isnil; draw the classic layout then. - Split the row, do not move it. Put the leading half in
frame(maxWidth: wrap.bandWidth, alignment: .leading), the trailing half in the same frame aligned trailing, give the rowwrap.headerHeight, and keep both halves vertically centred in it.contentInsetsis 0 on every edge while you are wrapped, so after the one root padding the row already starts at the card's top edge, beside the camera, and its halves sit on the card's sides. Never add an offset, a top padding or a negative inset of your own. - Nothing in the middle. The space between the bands is under the camera housing. A title that does not fit its band drops its words and keeps its glyph (
ViewThatFits), it never runs under the notch. - Everything else starts under the row. The host makes
headerHeighttall enough that what follows it clears the camera, and grows your rectangle by whatever the wrapped row adds over the height you declared, so your fixed height stays the classic one.
The harness draws the wrapped stage beside the classic one, with the housing outlined, so a half that strays under the camera shows. See Shelf widgets for the full API.
Controls inside a widget are the flat wash
Every control a widget draws on the shelf is the flat wash of Droppy's own Pomodoro and Stay Awake (DroppyKit 1.21.0): a white wash, a semibold glyph and a 0.94 press, no glass, no hover, no outline.
DroppyWashCircleButtonStylefor a symbol andDroppyWashPillButtonStylefor a label.neutralfor everything but the one action the widget is for, which takes.accent(color): Start Timer, Record, Send.- 34pt alone on the shelf, 28pt beside another widget, 28pt in the row wrapped beside the camera (Stay Awake's wrapped control), 24pt for a small choice or a control inside a list row.
- Pass
usesAdaptiveForegroundsasusesAdaptiveForegrounds, so the wash follows the host's transparent notch. - One family per widget. Never wash and glass in one widget, and never a bordered chip, a white button or a wash of your own.
Button("Start") { start() }
.buttonStyle(DroppyWashPillButtonStyle(
height: context.isCompact ? 28 : 34,
tone: .accent(.orange),
usesAdaptiveForegrounds: context.usesAdaptiveForegrounds
))
Button { reset() } label: { Image(systemName: "arrow.counterclockwise") }
.buttonStyle(DroppyWashCircleButtonStyle(
diameter: context.isCompact ? 28 : 34,
usesAdaptiveForegrounds: context.usesAdaptiveForegrounds
))Three things are not this rule's buttons:
- Content that is the widget. A grid of chips that are the things the widget controls (a room's lights, the keys of a keyboard) is the widget's content and keeps the look of its content.
- The live activity row. It is Dynamic Island chrome, and its 24pt controls keep
DroppyLiveActivityControlStyle. - Settings panes. They use unmodified macOS controls:
Buttonwith.borderedfor ordinary actions,.borderedProminentfor a primary action,.borderlessfor an inline one,Togglewith.switch, andPickerorMenufor choices. Let macOS supply their sizing, backgrounds, focus rings and interaction feedback, userole: .destructivefor destructive actions, and do not wrap them in glass, capsule fills, custom borders or hover scaling.
The Liquid Glass styles (DroppyCircleButtonStyle, DroppyQuietButtonStyle, DroppyAccentButtonStyle and DroppyGlassButtonStyle) stay in the SDK and keep working for every droplet that already uses them. Use them for a control floating over content outside a widget, like the shelf's floating close button, not for a new widget's controls.
Spacing and radius
DroppySpacing is a 4pt grid; DroppyRadius is a tiered scale. Pair every radius with style: .continuous — a circular corner beside Droppy's continuous ones is visible.
RoundedRectangle(cornerRadius: DroppyRadius.medium, style: .continuous)
.padding(DroppySpacing.smd)No outlines, no gradients, sentence case
Droppy's design language is flat solid surfaces separated by fill contrast:
- No borders or outlines on cards or buttons. Separate surfaces by fill.
- No gradients, with one exception the SDK already owns — the Settings sidebar's icon tiles.
- Sentence case everywhere. Never ALL-CAPS labels, including small ones.
- No decorative dots inside chips or tags. Plain tinted text pills.
Live activity chrome, not cards
A live activity is mini-HUD chrome. DroppyLiveActivityMetrics carries the real numbers: a 37pt row, 13pt glyphs, 12pt labels, 6pt content spacing, and 24pt circular controls. Use them rather than measuring from a screenshot.
Settings panes use the settings components
A droplet's settings pane is rendered inside Droppy's own Settings window, in the grouped form every native page is built from. Root it in DropletSettingsPane and build it from DropletSettingsCard and its rows, and every row is the Form's own row, so it matches every native page, including after Droppy's styling changes, because those components are kept in step with the app.
DropletSettingsPane {
DropletSettingsCard {
DropletToggleRow(title: "Show seconds", subtitle: "Ticks every second.", isOn: $showsSeconds)
DropletControlRow(title: "Clocks") { DropletValuePill(text: "\(count)") }
}
}Solo and paired are different layouts
A shelf widget alone gets the wide standalone canvas; sharing the row it gets a slice. These are different compositions, not one view at two widths. Switch on context.isCompact, never on a width comparison — see Shelf widgets.
Both notch shapes
context.surface is .builtInNotch on a MacBook and .dynamicIsland on a display without one, where Droppy draws a floating island instead. Content that assumes wings either side of a cut-out has nowhere to go in an island. The harness switches between them so you can check.
The physical notch is cleared for you, except on a strip
On a MacBook the camera housing sits inside every one of these surfaces, and the host keeps your content out of it. You do not pad for it, and padding for it anyway doubles the gap:
- Shelf widget and takeover. The shelf's row starts the cutout's height plus 10 below the screen edge and 48 in from each opened shoulder, then the 1.5pt slot inset; your rectangle begins there. The widget context's
contentInsetsinside that rectangle clears the rectangle's own rounded corner, not the notch: 8 against the 18pt card corner, and 0 for a solo island card, which the island shell clips. A solo widget that opted in to the wrap is the one that rises beside the camera instead: the host moves its rectangle up to 17.5pt below the screen edge, hands it the bands incontext.notchWrap, and still keeps everything under the row clear of the housing. - HUD card and expanded live activity. The card's content is inset by the cutout's height at the top and 48 at the sides, 16 all round on an island (
expandedHUDContentInsets(usesNotchStyle:notchHeight:)). - Compact live activity. You hand the host two accessories and it places them in the two wings beside the housing; nothing you draw can end up under it.
The one exception is a HUD strip. The host gives a strip the whole width of the closed notch plus its wings, with the compact family's 22pt symmetric padding on a notch and 12 on an island, and cannot split one view into two wings: on a MacBook the middle of that width IS the camera housing. Lay the strip out with its content at the two outer edges and nothing in the middle, the way Droppy's own caps-lock and Wi-Fi strips are, and never centre it. notchGeometry (host.environment.notchGeometry) carries closedWidth, the housing's width, and isHardwareNotch; the harness draws the housing's outline over the strip so a centred strip shows its mistake. The World Clock example's strip is the pattern: the glyph at the far left, the time at the far right.
Motion
Use DroppyAnimation's presets. Do not invent springs: Droppy's open and close timings are the app's, and a droplet animating on its own curve inside a host spring reads as lag.
Every motion around a view you hand the host is Droppy's own, run by the same code as Droppy's own surfaces: a live activity row and a HUD appear, grow and close exactly like Droppy's. Never put an entrance transition on those views, or it plays twice. For content you swap while a surface is up, use DroppyTransition: the same transitions, the same blur and the same springs (see Live activities).
Icons follow the user's icon style
macOS 26 draws every app icon Default, Clear or Tinted, light or dark, in the tint the user picked (System Settings, Appearance, Icon and widget style), and Droppy draws droplet icons the same way. Your droplet's own icon needs no change: a Droppy that embeds DroppyKit 1.21.0 draws it in the user's style, with the real Liquid Glass, from the compiled icon droppykit build has put in every bundle since 1.6.2. What you control is how it looks in the five styles that are not Default, through the dark and tinted specializations in Icon Composer; see The icon.
A view of your own that draws artwork in the same style reads it the way the host's icons do, a pinned style first and the user's otherwise:
@Environment(\.droppyIconStyle) private var pinned
var body: some View {
let style = pinned ?? DroppyIconStyleObserver.shared.style
// style.variant: .default, .clear or .tinted
// style.appearance: .light or .dark; style.tint: the tint colour
...
}Reading DroppyIconStyleObserver.shared.style from a body redraws the view when the user changes the style. It needs minAPI 1.21.0; on macOS 14 and 15 it is always Default, in the interface's light or dark. The harness's Overview draws your built icon in all six styles.
Accessibility
Every live activity carries an accessibility title and every HUD an accessibility label — they are required parameters, not options, because a surface this small is one a screen reader user cannot fall back on reading.
Build a surface
Good citizen
How a droplet shares Droppy's process, the Mac's battery and the user's trust.
A droplet runs inside Droppy, on its main actor, with Droppy's permissions. Whatever the droplet does, Droppy does. A stall in a droplet is a stall in the notch, a poll nobody looks at is Droppy draining the battery, and a token in plain text is Droppy leaking it. Each rule on this page is one a submission broke, and review sends it back for.
Work only while someone can see it
A widget nobody is looking at needs no fresh data. Refresh while it is on screen and stop when it is not.
The surest signal is the widget's own view. Droppy mounts it while it is on the open shelf (or shown as a preview) and takes it down when the shelf closes, so a .task on its root runs exactly as long as somebody can see it, and SwiftUI cancels it for you:
var body: some View {
content
.task {
while !Task.isCancelled {
await model.refresh()
try? await Task.sleep(for: .seconds(60))
}
}
}Work that lives in your model rather than in a view follows the shelf. With shelf-read declared, isExpanded says whether the shelf is open and didChange fires when it opens, closes or swaps widgets:
// In activate(host:). Keep the cancellable and cancel it in deactivate().
shelfObserver = host.shelf.didChange.sink { [weak self] in
guard let self else { return }
if self.host?.shelf.isExpanded == true { self.startPolling() } else { self.stopPolling() }
}installState reports what the user has switched on: isEnabled and the widgets placed on a shelf layout, activeWidgetIDs. In Droppy after 16.0.1, activeWidgetIDs holds your widgets on the user's Custom Shelf, and statePublisher fires when the user places or removes one. Subscribe to it in activate(host:) rather than reading the state once.
Droppy 16.0.1 and earlier, and Playground 1.0.27 and earlier, report an empty set even while your widget is placed. While you support them, treat an empty set as unknown, never as "not placed". A placed widget is not necessarily on screen, and a widget opened on its own from the Widgets page is not placed, so gate refreshes on the view and the shelf as above.
Some work has to run unseen: a timer the user started, a download, a live activity that is counting down. That is fine. A poll for a widget nobody has open is not, and neither is polling faster than the data changes.
Keep the main actor free
Droppy draws the notch, the shelf and every animation on the main actor, and your droplet shares it. Anything that can wait on something else does not belong there:
- Files. Reading, writing, listing a folder, decoding a large JSON file.
- Accessibility. Every
AXUIElementcall is a round trip to another app, and it waits as long as that app is busy. Set a shortAXUIElementSetMessagingTimeoutand make the calls off the main actor. - Processes. Launching a tool and waiting for it, see below.
- Anything synchronous over the network, and any
DispatchSemaphoreorwait()that holds a thread until something else answers.
Blocking calls go on a dispatch queue of your own, never on the main actor and never on Swift's cooperative threads, where a blocked thread holds up every other task. Hop back to the main actor with the result only.
Run processes with a deadline
A command-line tool can hang, print more than a pipe holds, or start children of its own that outlive it. Run it with a termination handler instead of a wait, read its output while it runs, give it a deadline, and stop its whole process group, not only the tool:
enum ToolError: Error {
case stopped
case failed(Int32)
}
/// Runs a tool and returns what it printed. It never blocks the calling actor,
/// gives up after `timeout`, and takes everything the tool started with it,
/// on the deadline or when the calling task is cancelled.
func runTool(_ tool: URL, _ arguments: [String], timeout: Duration = .seconds(10)) async throws -> Data {
let process = Process()
process.executableURL = tool
process.arguments = arguments
let output = Pipe()
process.standardOutput = output
process.standardError = FileHandle.nullDevice
let (exits, exit) = AsyncStream.makeStream(of: Process.TerminationReason.self)
process.terminationHandler = { finished in
exit.yield(finished.terminationReason)
exit.finish()
}
try process.run()
// Process starts the tool in a process group of its own, so a negative
// pid signals the tool and everything it started, and never Droppy.
let group = -process.processIdentifier
let deadline = Task {
try await Task.sleep(for: timeout)
kill(group, SIGTERM)
try await Task.sleep(for: .seconds(2))
kill(group, SIGKILL)
}
defer { deadline.cancel() }
return try await withTaskCancellationHandler {
// Read while the tool runs, on a queue of its own: a pipe that is only
// read after the exit fills up and stalls the tool.
let data = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .utility).async {
continuation.resume(returning: output.fileHandleForReading.readDataToEndOfFile())
}
}
for await reason in exits where reason == .uncaughtSignal {
throw ToolError.stopped
}
guard process.terminationStatus == 0 else { throw ToolError.failed(process.terminationStatus) }
return data
} onCancel: {
kill(group, SIGTERM)
}
}Keep the task that calls it, and cancel it in deactivate(): a tool still running when the droplet is switched off is stopped with everything it started.
Keyboard event taps get their own thread
If all you need is a hotkey, register it through DropletShortcutsService with global-shortcuts: there is no tap to run, and the user can change it on Droppy's Shortcuts page.
A CGEvent tap that has to see every keystroke runs on a thread and run loop of its own, never on the main run loop. On the main run loop every keystroke on the Mac waits for whatever Droppy is drawing, and macOS disables a tap that answers too slowly. Droppy's own media key tap is built this way:
/// A keyboard event tap on a thread and run loop of its own.
final class KeyTap: @unchecked Sendable {
private let lock = NSLock()
private var tap: CFMachPort?
private var source: CFRunLoopSource?
private var runLoop: CFRunLoop?
private var isStopped = false
private let onKeyDown: @Sendable (CGKeyCode, CGEventFlags) -> Bool
/// `onKeyDown` runs on the tap's thread. Return `true` to swallow the key.
/// Keep it short, and hop to the main actor for anything that touches UI.
init(onKeyDown: @escaping @Sendable (CGKeyCode, CGEventFlags) -> Bool) {
self.onKeyDown = onKeyDown
}
func start() {
let thread = Thread { [self] in run() }
thread.name = "Key tap"
thread.qualityOfService = .userInteractive
thread.start()
}
private func run() {
let mask = CGEventMask(1 << CGEventType.keyDown.rawValue)
guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: mask,
callback: { _, type, event, info in
let keyTap = Unmanaged<KeyTap>.fromOpaque(info!).takeUnretainedValue()
return keyTap.handle(type, event)
},
userInfo: Unmanaged.passUnretained(self).toOpaque()
), let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { return }
lock.lock()
// stop() may have run before this thread got here.
guard !isStopped else {
lock.unlock()
CFMachPortInvalidate(tap)
return
}
self.tap = tap
self.source = source
runLoop = CFRunLoopGetCurrent()
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
lock.unlock()
CFRunLoopRun() // Returns once stop() has invalidated the source.
}
private func handle(_ type: CGEventType, _ event: CGEvent) -> Unmanaged<CGEvent>? {
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
lock.lock()
if let tap { CGEvent.tapEnable(tap: tap, enable: true) }
lock.unlock()
return Unmanaged.passUnretained(event)
}
let key = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode))
return onKeyDown(key, event.flags) ? nil : Unmanaged.passUnretained(event)
}
/// Call from deactivate(). Safe before, during and after start().
func stop() {
lock.lock()
isStopped = true
let tap = self.tap, source = self.source, runLoop = self.runLoop
self.tap = nil
self.source = nil
self.runLoop = nil
lock.unlock()
if let tap { CGEvent.tapEnable(tap: tap, enable: false) }
if let runLoop { CFRunLoopStop(runLoop) }
if let source { CFRunLoopSourceInvalidate(source) }
if let tap { CFMachPortInvalidate(tap) }
}
}A tap needs the accessibility capability. Use .listenOnly when you only watch keys, and start it when the feature is switched on, not at activation.
Off until the user turns it on
Anything that takes something over, costs battery, holds the notch or sends data starts off, and the user switches it on in your settings pane. Examples review has sent back:
- A global shortcut that replaces a system or app shortcut, or a key the droplet intercepts, such as ⌘Q or ⌘Tab.
- Taking over the shelf, or a live activity that stays pinned to the notch.
- Bluetooth scanning, location, or a background scan of any kind.
- A request that sends something about the user, such as an IP address lookup for the weather, before the user has said yes to it.
- A choice made for the user: a preset location, a guessed account, "show everything" where the useful default is less.
The safe default is off and empty, and the empty state tells the user how to start, ideally with a button that opens your settings (openSettings()).
Secrets go in the Keychain
DropletPreferencesService is plain UserDefaults in Droppy's own domain, under droplet.<your-id>.<key>: anything that can read Droppy's preferences file reads it in plain text. API keys, tokens, passwords and one-time-password seeds go in the Keychain:
enum Secrets {
static let service = "droplet.worldclock" // your droplet id
static func save(_ value: String, for account: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
var item = query
item[kSecValueData as String] = Data(value.utf8)
return SecItemAdd(item as CFDictionary, nil) == errSecSuccess
}
static func read(_ account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(decoding: data, as: UTF8.self)
}
static func delete(_ account: String) {
SecItemDelete([
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
] as CFDictionary)
}
}Name the service after your droplet id so it never meets another droplet's item, and give the user a way to remove it: a Sign out or Remove key button that calls delete.
Read only what is yours
- Your files go in
containerDirectory, a private folder per droplet that survives updates. - Read a file elsewhere only when the user hands it to you, through an open panel or a drop.
- Never read another app's container, cache or database, another droplet's folder or preferences, or Droppy's own. Another app's private files are not an API: they change without notice, and reading them is reading the user's data behind that app's back. Ask the app's maker for a real interface.
English first, sentences whole
The Store is used worldwide, so English is the base language and every other language is a localization on top of it.
- The manifest, the changelog, the README and every default string are in English.
name,summaryanddescriptionindroplet.jsonincluded. - Other languages follow Droppy's. Your code runs in Droppy's process, so
Bundle.mainis Droppy: pick your strings forBundle.main.preferredLocalizations.first, and fall back to English for a language you do not have. A droplet in one language inside a Droppy in another reads as broken. - One name. The manifest's
name, the widget title and the settings page say the same thing. - Whole sentences, never fragments. Word order and grammar differ between languages, so a sentence glued together from pieces ("api" plus " did not restart") cannot be translated. Keep each sentence whole, with placeholders for the values, and write a sentence per case where the value changes the grammar: "1 counter", "2 counters".
- Format numbers, dates and percentages with Foundation's
FormatStyle(value.formatted(.percent)), which puts the separator and the percent sign where the language does.
Choose your id once
The id in droplet.json is permanent from the first publish: it names your folder in the Store, your bundle, your preferences and every installed copy. Pick one that names the product, so a droplet called Freelancy is freelancy, not freelance-tracker. Before your first droppykit submit, renaming costs nothing: change id in droplet.json and your droplet's id in Swift. After it, it cannot change.
Use Droppy's menu bar extra
Never create your own NSStatusItem. A droplet's menu bar extra is a MenuBarExtraProviding conformance with the menu-bar capability: Droppy shows it as a tile in its Command Center, the panel under Droppy's one menu bar icon, the user arranges it with the other tiles, and it goes when the droplet is switched off.
Credit what you did not make
- Name every project, artwork, sound or data set you did not make yourself in your README's Credits section: whose it is, its licence and a link. Keep the licence header in every file you copied.
- Code under the GPL or the AGPL cannot go into a droplet, because a droplet runs linked into Droppy's own process. Ask before using anything under another copyleft licence.
- Artwork, sounds and data need a licence that allows distribution in a product people pay for. "Free to use" on a website is not one.
- Say so when your droplet is a port of somebody else's project, and have their permission.
Your own name and marks only
No other company's logo, icon or mark appears anywhere in your droplet: the widget, the settings page, the README or your Icon concept. Your droplet's name is yours, not another company's, and its icon is Jordy's (see The icon). When the droplet works with a service, say that in plain words where it matters ("Sign in with your GitHub account"), and draw your own glyph for what the droplet does.
Build a surface
Shelf widgets
Cards on the expanded shelf, in both the solo and paired layouts.
The shelf is Droppy's expanded surface below the notch. A widget is a card on it. The shelf owns one layout algorithm and widgets supply parameters to it, so the numbers you give in ShelfWidgetLayoutTraits are what the shelf lays out with — there is no per-widget special case in the host.
Declaring a widget
extension WorldClockDroplet: ShelfWidgetProviding {
public var widgetDescriptors: [ShelfWidgetDescriptor] {
[
ShelfWidgetDescriptor(
id: "clocks",
title: "World Clock",
systemImage: "globe",
layoutTraits: ShelfWidgetLayoutTraits(
preferredSoloWidth: 420,
preferredPairedWidth: 210,
contentHeight: .fixed(128),
// Alone on a Mac with a notch, the header row rises
// beside the camera. See "Wrapping around the camera".
notchWrap: ShelfWidgetNotchWrapTraits(headerHeight: 34, minimumBandWidth: 64)
),
searchKeywords: ["time", "timezone"]
)
]
}
}widgetDescriptors is read when the droplet starts and every time it broadcasts a state change (see below), so keep it stable, cheap and free of side effects.
Important. preferredSoloWidth and preferredPairedWidth are both required. They are typed as optional for binary compatibility only; a current host refuses a descriptor that omits either. nil does not select a fallback.
Changing what you offer
The list is not fixed at activation. Droppy reads widgetDescriptors again:
- when the droplet starts, stops, is updated or is removed;
- after the droplet broadcasts a state change: its
objectWillChangefires (when the droplet is anObservableObject, as everydroppykit newscaffold is), one of its keys inDropletPreferencesServicechanges, an@Observableproperty read during the previous read changes (when the droplet is not anObservableObject), orstatePublisherfires; - when the droplet calls
host.shelf.invalidateLayout(for:), or aShelfWidgetDynamicLayoutProvidingdroplet's invalidation publisher emits.
A burst of broadcasts costs one read, at most about four a second, and a read that changed nothing changes nothing on screen. What did change is applied live, without a relaunch:
- a widget id you start declaring appears in the shelf's widget picker, the Custom Shelf editor, Settings and search;
- an id you stop declaring leaves the shelf. Droppy keeps the user's placement of it, so it returns to the same slot when you declare it again;
- a changed title or symbol shows wherever the widget is listed;
- a changed width or height re-lays the open shelf out on its own spring.
So a widget that has nothing to show until the user configures the droplet can be left out of the list until then, rather than drawn empty:
public var widgetDescriptors: [ShelfWidgetDescriptor] {
guard host?.preferences.value(forKey: "apiKey", as: String.self) != nil else { return [] }
return [clocksDescriptor]
}Writing the key through host.preferences is the broadcast. Droppy listens to the droplet object, so state that lives in another object reaches it only through one of the broadcasts above: forward that object's objectWillChange to the droplet's own, or store the value in preferences. (@Observable state is heard wherever it lives, but only for a droplet that is not itself an ObservableObject.) Return the same descriptor for the same state, because Droppy compares each read with the last one to decide what to update.
The widget's view is yours to keep current. A broadcast re-reads descriptors; it does not rebuild a mounted view whose descriptor is unchanged. Droppy 16.0.1 and earlier read the list only when the droplet started, stopped, was updated or was removed, and when it called invalidateLayout(for:).
Solo and paired
Solo — the widget is alone on the shelf and gets the wide standalone canvas. placement is .solo, isPaired and isCompact are false.
Paired — two to four widgets share a row. Each gets its settled slot width, isPaired and isCompact are true, and placement is .leading, .middle, or .trailing.
These are different compositions. Solo has room for a row per item; paired has room for one number. Branch on context.isCompact:
var body: some View {
VStack(alignment: .leading, spacing: DroppySpacing.sm) {
header
if context.isCompact {
// The room a taller row gives you opens here, so the last row
// ends on the row's bottom line beside its neighbours.
Spacer(minLength: 0)
compactBody
} else {
fullBody
Spacer(minLength: 0)
}
}
.padding(context.contentInsets)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}The padding, the full-size frame and the header row are the layout every Droppy widget shares; Design guidelines spells the rules out, and its controls. contentInsets is the clearance for the rounded corner the host clips the rectangle with: 8 on every edge alone under a notch (an 18pt corner), 0 for a solo card filling the island, which the island shell clips instead so the island keeps one even gap, 0 on every edge in a grouped row, and 0 while the widget is wrapped around the camera; the host clips those last two square.
A row shares its lines (1.21.0). Every widget in a row starts its first row on the row's top line and ends its last row on the bottom line, 17.5pt over the shell's floor, and keeps the same gap to the divider, so Droppy clips a grouped card square and hands it zero insets: your rectangle's edges are those lines. The row is as tall as its tallest widget, so yours may be given more height than it declared; the composition above puts that room between the header and the last row instead of under it. The shelf's chrome has already inset the rectangle, so pad nothing else on top of it, and never a corner inset of your own. The root gets no card: no background, fill, outline or rounded box. The one exception, artwork that is itself the widget's content and fills the tile, is in Design guidelines.
Every control the widget draws is the flat wash of Droppy's Pomodoro and Stay Awake (DroppyKit 1.21.0): DroppyWashCircleButtonStyle for a symbol, DroppyWashPillButtonStyle for a label, .neutral for everything but the one action the widget is for. 34pt alone, 28pt in a row or beside the camera, 24pt inside a list row:
Button { refresh() } label: { Image(systemName: "arrow.clockwise") }
.buttonStyle(DroppyWashCircleButtonStyle(
diameter: context.isCompact ? 28 : 34,
tone: .neutral,
usesAdaptiveForegrounds: context.usesAdaptiveForegrounds
))Never branch on a width comparison. The same width means different things on a notch and on an island, and a threshold that works today breaks when a widget is added beside yours.
The numbers are the widget's rectangle
Every number in ShelfWidgetLayoutTraits describes the widget's rectangle: the area your view draws in, in points at the Regular shelf size. That is the size availableSize reports back, and the size the harness renders. The shelf adds its own chrome around it (a 1.5pt slot inset on every edge, the side insets, the cutout clearance on a notch), and the Enlarged shelf size scales the rectangle with everything else. DroppyShellMetrics carries the chrome, if you ever need it; you rarely do. The SDK's metric names call this rectangle the card, and that is the only sense in which there is one.
There is no card drawn around it. Droppy paints nothing behind a widget, and almost every one of Droppy's own widgets lays its content straight onto the shelf's black, with no fill and no border. Do the same: no background on your root view, no outline, no rounded box. The harness draws a faint hairline where the rectangle ends so you can see it; Droppy draws nothing there.
The World Clock above is a 420 × 128 rectangle alone and 210 × 128 in a row. Its solo shell on a notch is 420 + 3 + 96 = 519 points wide; the shelf does not care, it opens to whatever the widget needs.
One floor: the shell never opens narrower than shelfMinimumWidth(usesNotchStyle:) (451 on a notch, 405 as an island), and a solo card fills whatever slot that leaves. A card that declares less than shelfMinimumSoloCardWidth(usesNotchStyle:) (352 on a notch, 370 as an island) is stretched to it, and the harness shows you the stretched size. Declare a solo width at or above the floor and lay out to context.availableSize.width rather than to the number you declared.
Height
ShelfWidgetContentHeight is .standard or .fixed(_:), and it is the rectangle's height. .standard is shelfWidgetStandardContentHeight, 93 points, the strip every widget gets when it says nothing; anything with a second row wants .fixed. .fixed is clamped to shelfWidgetFixedContentHeightRange (48 through 480), and the harness marks a clamped height in red. The height includes contentInsets: content sized to fit exactly budgets 16pt for it under the 18pt corner and none alone on the island, and a height computed in currentLayoutTraits(for:), which gets no context, budgets the 16.
A grouped row honours the same height as the solo widget, less the 16pt the corner insets take alone under a notch (a grouped card has none). If your compact composition needs less, it still gets that height, and a taller widget beside it can give it more; there is one number, so declare the one both compositions fit in and let one Spacer take what is left.
For content-adaptive sizing, a height that depends on the data and not just the type, change what widgetDescriptors returns. When the change comes with one of the broadcasts listed under "Changing what you offer" above, that is all it takes. When it does not, tell the host to re-read it:
host.shelf.invalidateLayout(for: "clocks")The host re-reads your descriptors and resizes the open shelf on its own spring when the height changed. Change the height when your model crosses a size threshold, not on every keystroke. A widget that re-measures continuously is a shelf that never settles.
Keeping the shelf open
The shelf closes a moment after the pointer leaves it and on any click outside it. That is right for a widget the user reaches into and wrong for one they watch from across the screen: a teleprompter reading out, a countdown, a live transcript. While that work runs, hold the shelf open (shelf-write, DroppyKit 1.5.0):
host.shelf.setHoldsOpen(true) // the script starts
host.shelf.setHoldsOpen(false) // it stops, finishes or is paused for goodThe hold counts only while one of your widgets is on the expanded shelf, so it cannot pin the user's other widgets open, and a shelf reopened onto your widget while the work still runs is held again without you doing anything. It also keeps the shelf up over a full-screen app. The user can still close it: while a hold counts Droppy draws its floating close button under the shelf, a swipe up and the shelf shortcut still work, and so does close(). Your widget should still offer its own way to stop. Release the hold the moment the work ends; Droppy releases it for you when the droplet deactivates.
Setting up from the widget
A widget that has nothing to show until the user configures it can send them straight to its settings pane. The "Set up" button calls openSettings() (DroppyKit 1.5.0), which opens Droppy's Settings on your droplet's own page:
Button("Set up") { host.workspace.openSettings() }Ask for permissions there, not from the shelf: a system alert raised while the shelf is open is drawn underneath it. Through DropletPermissionsService Droppy moves the notch out of the way, and answers .unavailable for microphone or speech-recognition on a build that cannot prompt for them rather than ending the process.
Focus
keyboardFocusable tells the shelf your widget's primary surface is a text field the user types into. When that widget opens alone, the shelf makes its panel key after the view mounts, so the cursor lands without an extra click. For a field that appears after the widget is already visible (a menu that turns into a name field), call requestKeyboardFocus() when the field appears:
TextField("Name", text: $name)
.onAppear { host.shelf.requestKeyboardFocus() }The request is refused unless one of your widgets is on the expanded shelf and declared keyboard-focusable, and it returns whether the panel became key. Once granted, the panel stays eligible for the keyboard for as long as that widget is on the shelf, including while your droplet's page is open in Settings, which is where the notch panel otherwise yields the keyboard to the Settings window. Do not call makeKey() on the window yourself, and do not poll canBecomeKey: the host decides both. Non-typing widgets never steal focus.
Prewarming
never opts out of being built before it is shown. Use it when construction is expensive or has side effects; the default is to allow prewarming, which makes the shelf open feel instant.
Both notch shapes
context.usesIslandCurvature is true only for a solo card filling a floating island, the one card the host does not clip at a card corner (the island shell clips it); a solo card under a hardware notch is clipped at the 18pt corner, and a grouped row on either shape is clipped square. context.surface says which shape the shelf is. context.usesAdaptiveForegrounds asks you to let the host tint your foregrounds.
Wrapping around the camera
On a Mac with a notch, a widget that has the shelf to itself can rise into the two bands beside the camera, the way Droppy's own Notes, Pomodoro and Calendar do: its header row splits, one half left of the notch and one right of it, and the rest starts under the camera, so the open shelf is shorter. It is opt-in (DroppyKit 1.21.0) and nothing changes for a widget that does not ask.
Declare the classic header row's height, inside contentInsets, and the narrowest band its halves fit in, in notchWrap:
ShelfWidgetLayoutTraits(
preferredSoloWidth: 460,
preferredPairedWidth: 230,
contentHeight: .fixed(220),
notchWrap: ShelfWidgetNotchWrapTraits(headerHeight: 34, minimumBandWidth: 64)
)When the host wraps the mount, context.notchWrap is a ShelfWidgetNotchWrap: the row's height and each band's usable width, both inside contentInsets, whose top is then zero, so the row starts at the card's top edge exactly where Droppy's own wrapped rows do. Keep applying .padding(context.contentInsets) once at the root and never offset the row yourself. Put the title in the leading band and the header's control in the trailing one, never anything in between (that is under the camera), and start the rest of the widget under the row. When it is nil (a grouped row, the Dynamic Island, an external display, a band too narrow, or a Droppy older than the SDK release) draw the classic header. The host grows the rectangle by what the wrapped row adds over the height you declared, so your fixed height stays the classic one.
The view, with the classic header as the fallback. The control is the wash disc at 28pt in the band and 34pt in the classic row:
VStack(alignment: .leading, spacing: DroppySpacing.sm) {
if let wrap = context.notchWrap {
HStack(spacing: 0) {
// The title drops its words before it reaches the camera.
ViewThatFits(in: .horizontal) {
Label("Notes", systemImage: "note.text")
Image(systemName: "note.text")
}
.frame(maxWidth: wrap.bandWidth, alignment: .leading)
Spacer(minLength: 0)
addButton(diameter: 28)
.frame(maxWidth: wrap.bandWidth, alignment: .trailing)
}
.frame(height: wrap.headerHeight)
} else {
HStack {
Label("Notes", systemImage: "note.text")
Spacer(minLength: 0)
addButton(diameter: 34)
}
}
list
}
.padding(context.contentInsets)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)Keep the two halves short. A band on the Regular shelf is about 60 to 120 points, depending on your solo width and the Mac's notch, and a title should shrink or drop secondary text before it reaches the cutout's edge. Leave the leading half 8 points clear of the notch. The wrap needs a real notch: on a display without one the Notch shape draws no cutout to wrap around, so the host keeps the classic layout there. The harness's Shelf widgets page draws the wrapped stage for a widget that declares it, at the host's numbers, with the camera housing outlined.
Build a surface
Live activities
The compact row beside the notch, and the detail behind it.
A live activity is Droppy's version of the Dynamic Island: a compact status that stays visible while something is happening, in the wings either side of the notch — or inside a floating island on a display without one.
Only one droplet holds the compact seat at a time. You publish a state; the host decides who wins.
Publishing state
liveActivityState is a publisher. Emit a LiveActivityState while you have something to say, and nil to stand down.
extension WorldClockDroplet: LiveActivityProviding {
public var liveActivityState: AnyPublisher<LiveActivityState?, Never> {
activitySubject.eraseToAnyPublisher()
}
private func publishActivity() {
activitySubject.send(
LiveActivityState(
priority: 200,
accessibilityTitle: "Amsterdam \(time)",
isInteractive: false
)
)
}
}Emitting every second is fine — the host diffs before it redraws. What is not fine is holding the seat when you have nothing to show: publish nil the moment your task ends.
priority decides who wins the seat. Keep it honest: a timer that is counting down outranks a status that merely exists, and a droplet that pins itself high is one users turn off.
The compact presentation
Two builders, one per wing:
public func makeCompactLeading() -> AnyView {
AnyView(
Image(systemName: "globe")
.font(.system(size: DroppyLiveActivityMetrics.iconSize, weight: .medium))
.foregroundStyle(AdaptiveColors.notchSurfacePrimaryText)
.padding(.trailing, DroppySpacing.sm)
)
}
public func makeCompactTrailing() -> AnyView {
AnyView(
Text(time)
.font(.system(size: DroppyLiveActivityMetrics.labelFontSize, weight: .medium, design: .rounded))
.monospacedDigit()
.foregroundStyle(AdaptiveColors.notchSurfacePrimaryText)
.padding(.leading, DroppySpacing.sm)
)
}The space is small and fixed: 70pt per wing at rest, 120pt when the row is interactive. Use monospacedDigit() on anything that ticks, or the row jitters as digits change width.
Hover and the shelf
A droplet's live activity is a compact row and nothing else, exactly like Droppy's own media, AI coding and File Tray activities. Hovering it or clicking it does what hovering or clicking any of Droppy's activities does: it opens the shelf, where your shelf widget is. The row hides while the shelf is open and comes back when it closes.
makeExpanded(context:) is not mounted by Droppy. It stays a requirement so droplets already built keep loading, but no card grows out of the row. Controls that must stay reachable, Stop and Start above all, belong in a shelf widget.
How content appears
Every motion around your row is Droppy's own, run by the same code that runs for Droppy's activities: the row unfolds out of the notch edge when it takes the seat, the shelf opens and closes on its own springs, and the row returns the way Droppy's own activities return after the shelf closes. You hand the host views; the views take no entrance transition of their own, or the entrance plays twice.
What you do animate is what changes inside the row while it is up, such as a glyph that flips in a wing. Use DroppyTransition, which carries Droppy's own curves:
Image(systemName: isCleaning ? "stop.fill" : "keyboard")
.id(isCleaning)
.transition(DroppyTransition.compactContent)DroppyTransition is 1.8.0 API, so a droplet that uses it declares minAPI 1.8.0.
Seats
seat reports what you currently hold:
compact— the primary seat.secondary— your activity rides the round companion pill beside one of Droppy's own, drawn bymakeCompanionCompact(context:). Only an activity whose state setsjoinsPersistentActivitySetis seated there, after Droppy's own companions. Draw for the context'sslotSize, the square Droppy's own companions draw their mark in: a glyph, a ring or a few characters, never your wing's layout. Anything larger is scaled down to it and clipped. A flick or a tap on the pill promotes your activity tocompact, as with Droppy's own companions, and the activity it displaced takes the pill.none(_:)— with a reason. Droplets seeidlewhen they publish nil,outrankedwhen another droplet wins, andsurfaceSuppressedwhile Droppy's own surfaces own the notch (media, timer, recording, meeting, HUD up, shelf open).pairFull,displayIneligible, anduserDisabledare defined for future use and are not awarded to droplets today.
liveActivitySeatDidChange(_:) tells you when it moves. Use it to stop work nobody can see. Losing the seat is normal, not an error. A droplet holds compact only when none of Droppy's own activities want the notch; when media plays it moves to the companion pill (or stands down, if it does not join the persistent set), and when the shelf opens it stands down.
To stand down before your publisher catches up:
host.liveActivity.yield(reason: .idle)Interactive rows
Set isInteractive on the state when your row has controls. The host widens the wings, and DroppyLiveActivityControlStyle gives you the 24pt circular control Droppy's own media row uses. Do not use a glass button style in this row; that chrome is for shelf widgets and settings, not island chrome.
Button { skip() } label: { Image(systemName: "forward.fill") }
.buttonStyle(DroppyLiveActivityControlStyle(prominence: .accent))Publish one stable state and keep it stable: fixed priority and fixed isInteractive, only accessibilityTitle ticks. Flapping either flag resizes the shell and drops hover. Publish nil only when the task truly ends, never to represent an idle toggle. Start and Stop belong in your shelf widget, which exists whether or not the row is published.
The companion pair
Droppy's own activities can pair as island plus detached pill. External droplets do not hold the pill seat today, so design the compact row to be complete on its own.
When that changes, the second renders through makeCompanionCompact(context:) with the slot size in context.slotSize. Return a narrower composition; the default reuses your trailing wing, which is usually too wide.
Build a surface
Expanded surfaces
A takeover on the notch, for something that needs the whole shelf.
An expanded surface replaces the shelf's normal contents with yours: a picker, a form, a full view of something a widget can only summarise. It is the biggest thing a droplet can put on screen, and the one that most needs restraint — while it is up, the user cannot see anything else Droppy would have shown.
Requires the expanded-surface capability.
Declaring one
extension WorldClockDroplet: ExpandedSurfaceProviding {
public var expandedSurfaces: [ExpandedSurfaceDescriptor] {
[
ExpandedSurfaceDescriptor(
id: "clocks-detail",
title: "World Clock",
systemImage: "globe",
suppresses: [.shelfWidgets, .autoCollapse]
)
]
}
}What it suppresses
ExpandedSurfaceHostSuppression is the host chrome your surface turns off while it is presented. Ask for the least you need:
| Option | Turns off |
|---|---|
shelfWidgets | The normal widget row |
favoritesBar | The favourites strip |
floatingNavLane | The floating navigation lane |
notificationBanners | Banners over the shelf |
autoCollapse | The shelf closing on its own |
outsideClickDismissal | Clicking away closing it |
swipeGestures | Swipe-to-close |
takeover is the full set. Reach for it only when a stray click really would lose the user's work: suppressing outsideClickDismissal means the obvious way out stops working, and a surface that traps someone is worse than one that closes early.
Sizing
public func expandedSurfaceSize(
_ id: ExpandedSurfaceID,
fitting proposal: ExpandedSurfaceSizeProposal
) -> CGSize? {
CGSize(width: proposal.standardSize.width, height: CGFloat(clocks.count) * 44 + 64)
}Return nil to take proposal.standardSize. The proposal also carries maximumSize, the surface kind, and the display, so a surface can be shorter on a small screen.
The shelf asks on its layout passes, and again while your surface is up each time the droplet broadcasts a state change: its objectWillChange fires (when the droplet is an ObservableObject), one of its keys in DropletPreferencesService changes, an @Observable property read during the previous call changes (when it is not), or statePublisher fires. That ask repeats the shelf's last proposal, and the shelf re-lays itself out only when your answer changed, so a surface that grows (the clock list above gaining a row) resizes the shelf without you doing anything else. A burst of broadcasts costs one call, at most about four a second.
Droppy hears the droplet object, not the provider. When the provider is a separate object, a session the droplet owns, forward its objectWillChange to the droplet's own, or store the value in preferences. (@Observable state is heard wherever it lives, but only for a droplet that is not itself an ObservableObject.) Droppy 16.0.1 and earlier asked only on layout passes the shelf made for its own reasons, so a surface that grew stayed at its old size until something else moved the shelf.
Changing a surface while it is up
expandedSurfaces is read when you present, to find the descriptor for the id you asked for. A presentation keeps that descriptor until it is dismissed, its suppressions above all: dropping autoCollapse halfway through would close the shelf under the user, and adding outsideClickDismissal would take away the way out they were using. A changed descriptor applies on the next presentation.
A broadcast never dismisses a presented surface. If expandedSurfaceProvider starts returning another object, the shelf draws from that one; if it returns nil, the shelf draws an empty surface until the user closes it. When a session ends, call dismissExpandedSurface(_:) before you take its provider away.
Every size here is the content area: what your view draws in, and what availableSize hands you back, clamped. The host adds the shelf's own chrome around it (the side insets, the cutout clearance, the slot gutter), so a 400-point-tall answer is a 400-point-tall surface, not a 400-point-tall shell with your bottom row behind the shell's edge. The standard size is a solo widget card in the shelf's minimum shell (352 × 93 on a notch, 370 × 93 as an island); the ceiling is two thirds of the display less the chrome.
Presenting and dismissing
let presentation = host.notchSurface.presentExpandedSurface(
ExpandedSurfacePresentationRequest(surfaceID: "clocks-detail", opensShelf: true)
)ExpandedSurfacePresentation identifies the presentation, not the surface, and the difference matters. Tearing a takeover down runs after the close spring settles, and by then the user may have summoned the same surface again — a teardown keyed on the surface id would kill the fresh one. Capture the presentation you started with and compare before acting on a delayed callback.
expandedSurfaceDidDismiss(_:presentation:reason:) is called once per presentation, for every reason including your own dismissal. ExpandedSurfaceDismissalReason tells you which: the user collapsed the shelf, another surface pre-empted yours, the display changed, the shelf was turned off, or host policy ended it.
Build a surface
Settings panes
Your droplet's own page inside Droppy's Settings.
A droplet with anything to configure provides a settings pane. It is rendered on the droplet's page in Droppy's Settings window, inside the same grouped form every native page is built from.
Build it from the SDK's settings components and it matches the rest of the app, including after Droppy's styling moves, because those components are kept in step with the host.
Providing one
extension WorldClockDroplet: SettingsPaneProviding {
public func makeSettingsPane(context: SettingsPaneContext) -> AnyView {
AnyView(WorldClockSettings(droplet: self))
}
public var settingsSearchEntries: [SettingsSearchEntry] {
[SettingsSearchEntry(title: "Show seconds", keywords: ["time", "clock"])]
}
}settingsSearchEntries puts your rows in Droppy's Settings search, so a user who types "seconds" lands on your pane instead of failing to find it. A result opens your droplet's page at its top; an entry's anchorID is not honoured yet.
Never repeat the page header
Droppy draws the top of your droplet's page itself, above your pane: your icon, your name, your summary and the maker badge. Your pane starts with its first setting. Do not open it with a card, a hero or a title that shows your icon or your name again; it reads as a mistake, twice the same thing stacked. The Droplet Store's validator refuses a pane that loads your own icon or shows your name as a title. An About page or a welcome window of your own, which have no such header, may show both.
The rules, in one place
These are the settings mistakes review sends back most. Each is explained further down this page or in Design guidelines.
- Root the pane in
DropletSettingsPane. A pane whose root is aVStackof cards is the legacy shape: it still works, but it never becomes a native page. - A page is a group of sections, never a
VStack. Cards andDropletSettingsSections go straight inside the pane. AVStack, a frame or a padding around them inside the Form makes them one row. - No
DropletSettingsDividerin a native pane. The Form separates the rows. - Section headers are
settingsSectionHeader("Title"), neverText("Title").font(.headline)or a header of your own. - A boolean is a switch row.
DropletToggleRow, never a picker with two options or a segmented control. Independent flags are one switch row each. - A glyph on every row of a card, or on none. A card that mixes rows with an
icon:and rows without one looks unfinished. - A subtitle is one short sentence. Anything longer belongs in a confirmation dialog or your README.
- System buttons.
.borderedfor an ordinary action,.borderedProminentfor the one primary action,.borderlessfor an inline one. The glass styles (DroppyGlassButtonStyle,DroppyQuietButtonStyle,DroppyAccentButtonStyle,DroppyCircleButtonStyle) are for the shelf, never for a settings page. - Native text fields.
TextFieldandSecureFieldwith their own border and focus ring, and no fill of your own. - Sentence case. Headers, row titles, options and buttons: "Show seconds", not "Show Seconds".
- No page header of your own. Droppy already shows your icon, name, summary and maker above the pane (see above).
The pane root
Wrap your sections in DropletSettingsPane (DroppyKit 1.9.0). The host places the pane in the grouped Form its own Settings pages are built from, and every row inside it renders as that Form's own row: the card is a Section, a toggle row is the Form's switch row, a picker row carries the System Settings pop-up face, a slider row is the Form's slider. Nothing in the pane draws a card, a divider or a row metric by hand, so the page is indistinguishable from System Settings and follows the OS when the OS moves.
DropletSettingsPane {
DropletSettingsCard {
DropletToggleRow(
title: "Show seconds",
subtitle: "Ticks the shelf widget every second instead of every minute.",
isOn: droplet.showsSecondsBinding
)
DropletSliderRow(
title: "Refresh",
value: "\(Int(interval))s",
binding: $interval,
range: 1...60
)
DropletControlRow(title: "Clocks") {
DropletValuePill(text: "\(droplet.clockCount)")
}
}
DropletSettingsSection {
settingsSectionHeader("Clocks")
} content: {
DropletSettingsCard {
ForEach(clocks) { clock in
DropletControlRow(title: clock.label) {
DropletValuePill(text: droplet.displayTime(for: clock))
}
}
}
}
}The pane's content is a list of sections, never a stack: a VStack inside a Form is one row. Put cards and DropletSettingsSections straight inside the pane, top to bottom, and leave the DropletSettingsDividers out (the Form separates the rows; a divider you place renders nothing there).
A header with a line of description under it, the shape System Settings uses for "Energy Mode", is a section whose header stacks the two:
DropletSettingsSection {
VStack(alignment: .leading, spacing: 2) {
settingsSectionHeader("Clocks")
Text("Shown in the widget, in this order.")
.font(.body)
.foregroundStyle(.secondary)
}
} content: {
DropletSettingsCard { ... }
}What the host does with the pane: on macOS 15 and later it recognises the root and mounts your sections inside its Form, under the droplet's hero and above the page's own sections (screenshots, capabilities, about). On macOS 14 there is no native form page, and the pane renders as a plain group of the cards below, with the rows drawing their own chrome.
A pane whose root is a VStack of cards, the shape every droplet built before 1.9.0 has, keeps working unchanged: the host keeps its page for it, the components render exactly what they rendered before, and the dividers are still what separates its rows before macOS 15.
The components
| Component | For |
|---|---|
DropletSettingsPane | The root of the pane |
DropletSettingsSection | A header over a card |
DropletSettingsCard | The card every group of rows sits in |
DropletControlRow | A title with a trailing control |
DropletToggleRow | A switch |
DropletSliderRow | A value with a slider, the pill click-to-type and the arrow keys live |
DropletStackedRow | A title over full-width content |
DropletGroupedPickerRow | A title with a native pop-up button |
DropletSettingsDivider | The hairline between rows of a legacy pane |
DropletValuePill | A read-only value |
Reach for the row, not for the SwiftUI control inside it. Every one of these is Droppy's own control, and the differences are the kind a reader notices without being able to name: DropletSliderRow is the Form's own slider with no tick marks (its step snaps the value instead of drawing one mark per step), its value is a pill you can click and type an exact number into, and the arrow keys nudge it with an accelerating repeat while held.
Sentence case
Section headers and row titles are sentence case: only the first word is capitalised, and proper nouns keep their capitals wherever they appear. Droppy enforces this on its own pages, and a droplet in Title Case is visibly a guest.
Droppy's language
Every title, subtitle, info tip, option and page link you hand these rows as a String is looked up in Droppy's own string catalog before it is drawn, so a row whose words Droppy already has reads in the language Droppy runs in. Wording that matches Droppy's ("Show in Command Center", "Keyboard shortcut") comes out translated with no work of yours; a string the catalog does not know is drawn exactly as you wrote it. Since 1.17.0, in any Droppy that runs the droplet, whatever DroppyKit it was built against.
Picker rows are pop-up buttons
settingsUnifiedPickerRow pairs a title with the pop-up button a System Settings row carries: the current choice as plain text, the up and down chevrons in a small round badge beside it, a checkmarked menu of every option. Build each option with settingsUnifiedSegmentLabel, which is how the row learns the titles it lists; an option drawn any other way keeps the SwiftUI menu picker instead. One option is selected at a time; a boolean is a DropletToggleRow, and independent flags are one switch row each, never options of one picker. The groupPosition and cornerRadius arguments on SettingsUnifiedSegmentedRow remain for compatibility with existing droplets and have no visual effect.
Use the SDK rows so switches, buttons and value pills align with Droppy's own pages. Keep text fields native so system borders and focus rings remain intact.
Inside a DropletSettingsPane the Form separates every row and places every control, and content under a row is the next row, which is what System Settings does too; settingsRowJoinsPrevious() does nothing there. In a legacy pane, on macOS 15 and later a card separates its rows on its own and recognises a DropletSettingsDivider you place so it is never doubled, and content that belongs to the row above it takes settingsRowJoinsPrevious(); on earlier releases the hand-placed dividers are all a card has.
Storage
Rows bind to DropletPreferencesService, which namespaces every key to your droplet:
var showsSecondsBinding: Binding<Bool> {
Binding(
get: { self.host?.preferences.value(forKey: "showsSeconds", default: true) ?? true },
set: { self.host?.preferences.setValue($0, forKey: "showsSeconds") }
)
}Preferences are plain UserDefaults, readable by anything that reads Droppy's preferences file. An API key, a token or a password goes in the Keychain instead; see Good citizen.
Opening it from a widget
openSettings() opens Droppy's Settings on this page, for a widget's "Set up" button (DroppyKit 1.5.0). It returns false when the droplet provides no pane, or on an older host.
Pages of your own
A droplet with more settings than one page holds gives its page pages of its own (DroppyKit 1.13.0), the way System Settings does: adopt SettingsPagesProviding, put a DropletSettingsPageLink in the pane for each page, and build the page in makeSettingsPage(id:context:).
extension WorldClock: SettingsPagesProviding {
func makeSettingsPage(id: String, context: SettingsPaneContext) -> AnyView? {
id == "cities" ? AnyView(CitiesPage()) : nil
}
}
// In the pane:
DropletSettingsCard {
DropletSettingsPageLink(
"Cities", subtitle: "Which clocks, in which order.",
page: "cities", icon: citiesIcon
)
}
// The page:
struct CitiesPage: View {
var body: some View {
DropletSettingsPage {
DropletSettingsCard { ... }
}
}
}The page opens in Droppy's Settings window, not in one of yours: Droppy's sidebar stays beside it, its name is the toolbar title, and Back returns to your droplet's page. Root it in DropletSettingsPage, the grouped Form every Settings page is; a page that already is a SwiftUI Form with .formStyle(.grouped) can be returned as it is. Give every link in a card a tile, an Icon Composer render at 56 by 56 pixels, or none. openSettingsPage(_:title:) opens a page straight away, for a "Settings…" command of your own.
A page's controls in the toolbar
What a page does as a whole, adding an item to its list, its own on/off switch, a search over it, a menu of what can be done to it, goes where Droppy's own pages keep theirs: in Liquid Glass pills at the right of the Settings toolbar, on the page title's line (DroppyKit 1.18.0). Never draw a bar of buttons at the top of the page: it stands still while the page scrolls under it, which nothing else in Settings does.
extension WorldClock: SettingsPagesProviding {
func makeSettingsPageToolbar(id: String, context: SettingsPaneContext) -> AnyView? {
guard id == "cities" else { return nil }
return AnyView(CitiesToolbar(model: .shared))
}
}
struct CitiesToolbar: View {
@ObservedObject var model: CitiesModel
var body: some View {
HStack(spacing: DropletSettingsToolbarMetrics.pillSpacing) {
DropletSettingsToolbarSearchField(text: $model.query, prompt: "Search cities")
DropletSettingsToolbarButton("Add city", systemImage: "plus") {
model.addCity()
}
}
}
}DropletSettingsToolbarButton, DropletSettingsToolbarMenu, DropletSettingsToolbarSwitch and DropletSettingsToolbarSearchField are each one of Droppy's pills; DropletSettingsToolbarPill is the glass itself, for a control of your own. Droppy asks for the controls whenever its toolbar redraws, so keep their state in an object the page observes too. Keep the cluster narrow, a text pill or two, a round one and a switch: one the toolbar cannot hold beside the page title goes into AppKit's overflow menu whole.
Shortcuts you keep yourself
A shortcut Droppy registers for you (DropletShortcutsService) records on Droppy's Shortcuts page. A shortcut your droplet keeps itself records through DroppyShortcutRecorder (DroppyKit 1.14.0), which is Droppy's own recorder and bubble, never one of your own:
DroppyShortcutRecorder(
shortcut: $model.shortcut,
isTaken: { model.isAlreadyBound($0) },
onRecordingChanged: { model.pausesHotKeys = $0 }
)Confirming a change
With DroppyKit 1.19.0, ask through DropletConfirmationDialog from the initiating button. The host draws its own glass popup. Perform the change only when present returns true; Cancel, dismissal and a missing presenter all return false. Keep detailed consequences in the popup instead of the settings row.
Button("Import…") {
if DropletConfirmationDialog.present(
title: "Import settings?",
message: "Replaces your current setup after restart. A backup is kept.",
confirmTitle: "Import",
symbol: "square.and.arrow.down"
) {
prepareImport()
}
}Declare kit.minAPI as 1.19.0 or newer. The host installs its presenter before loading droplets. Hosts without a presenter, including the development harness, do not authorize the change.
Build a surface
Launchers
Put Droppy's commands, droplets, settings and content in a launcher you draw.
A droplet that draws a launcher (a command palette with its own window and search) can list everything Droppy offers beside its own entries: Droppy's commands, its droplets and their settings pages and widgets, the droplets that can be installed, Droppy's settings, and what only Droppy can search, such as clipboard history, notes, OCR results, recordings, tasks, the shelf and web answers.
Declare the launcher capability and minAPI 1.12.0 in droplet.json, then read launcher:
func activate(host: DropletHost) throws {
let launcher = host.launcher
guard launcher.isAvailable else { return }
index(launcher.catalog)
cancellable = launcher.catalogDidChange.sink { [weak self] in
self?.index(launcher.catalog)
}
}The catalog and the search
catalog is everything Droppy lists without a query. Index it with your own search, so its items rank, learn and take favorites like your own. Refresh it when catalogDidChange fires: a droplet was installed, or a setting switched a command off.
search(_:) answers a typed query with content, best first. Ask it as the query changes, after the keystrokes of a word still being typed, and drop an answer that arrives for a query that is no longer in the field. A content item's keywords hold the text it matched, which may be nowhere in its title.
Running an item
Hand an item's id back to perform(itemID:actionID:query:), with nil for the primary action or one of the item's actions. Then do what the outcome says:
dismiss: close the launcher at once and hand focus back to the app underneath. Droppy runs the action a moment later, once your window is gone, so a capture does not capture your launcher and a paste lands in the app the user was in.stay: keep the launcher up.presentDetail: showmakeDetailView(itemID:context:)inside your window, with a way back. It is a droplet's widget or settings page, or a web answer, and it takes the keyboard.failed(_:): say the message, in a sentence.
The panel's surface
Put DroppyPanelSurface behind your launcher's content and clip the content to the same corner radius. It is the surface Droppy's own launcher sits on and follows the user's Theming preference, Dynamic Glass included, so a launcher droplet looks like Droppy rather than like a second app.
The service
Items
DropletLauncherItemDropletLauncherIconDropletLauncherActionDropletLauncherOutcomeDropletLauncherDetailContext
The surface
Keep the pointer
Droppy runs as a background app, so a non-activating panel of its own gets its mouse moves only now and then while another app is active: rows light up late as the pointer sweeps them. Begin a DroppyPanelActivation right after ordering your panel front and finish it right before ordering it out (DroppyKit 1.15.0). Droppy becomes the active app while the panel is up, and the app it was summoned over gets the foreground back when it closes.
Build a surface
Tools for AI apps
Offer your droplet's actions to Claude, Cursor, Codex and other AI apps through Droppy's MCP server.
Droppy runs a Model Context Protocol (MCP) server that AI apps on the Mac connect to. Once the user allows it, an app such as Claude can search the Droplet Store, switch droplets on and off, and run what a droplet offers. Adopt MCPToolProviding to offer typed tools there, and declare minAPI 1.21.0 in droplet.json:
extension WorldClockDroplet: MCPToolProviding {
var mcpTools: [DropletMCPTool] {
[
DropletMCPTool(
name: "add_city",
title: "Add a city",
summary: "Adds a city to World Clock. Returns the cities it now shows.",
inputSchemaJSON: #"{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}"#
),
]
}
func performMCPTool(named name: String, argumentsJSON: String) async throws -> String {
guard name == "add_city" else { throw WorldClockError.unknownTool(name) }
let arguments = try JSONDecoder().decode(AddCity.self, from: Data(argumentsJSON.utf8))
try model.add(city: arguments.city)
return try String(decoding: JSONEncoder().encode(model.cities), as: UTF8.self)
}
}The AI app sees each tool as droplet_<your id>_<name>, with your summary as the description it reads and your schema as the arguments it sends. What you return goes back to it as text, or as structured content when it is JSON. An error you throw comes back as a failed call that names your droplet.
Writing good tools
- Name a tool for what it does (
add_city,start_recording), lowercase, at most 32 characters. - Write the summary for a reader who has never seen your droplet: what it does, what it takes, what it returns.
- Treat the arguments as untrusted input. They come from a model, which may have read them in a web page or a file. Check every field and never run anything an argument names.
- Keep a call short. Droppy answers a call that runs longer than 60 seconds with a timeout and cancels your task.
- Keep
mcpToolscheap and stable: Droppy reads it when your droplet starts and whenever droplets change. At most 32 tools are listed; one with an invalid name, an empty title or summary, or a schema that is not a JSON object is left out.
Shortcut actions show up too
Every action you register through DropletShortcutsService is offered to AI apps as well, under its title, through Droppy's droplet_run_action tool. It takes no arguments and runs the same handler your shortcut runs. A droplet whose actions are "do what the shortcut does" needs nothing more.
What the user decides
Nothing reaches an AI app until the user switches on Droppy's MCP droplet (Settings, Droplet Store, MCP), approves that app, and allows "Browse and manage droplets". Mark a tool isDestructive when it deletes or overwrites something the user cannot get back: Droppy then asks the user before every call, and lists the tool only while "Delete and clear items" is allowed too.
Build a surface
The icon
The icon is Jordy's to make; the creator avatar is yours.
A droplet in the Store carries two pieces of artwork: its icon and its creator avatar. You make the avatar. You do not make the icon.
The droplet icon
Jordy designs every Store droplet's icon himself, in Icon Composer and in Droppy's current icon style. Droppy draws a droplet's icon at seven sizes, on light and dark chrome, behind glass, and in every icon appearance macOS offers: default, dark, clear and tinted. Icons made together, by one hand, adapt properly in all of them and make the Store read as one set rather than a collection of styles.
So the icon is not part of your work:
droppykit newwrites a placeholder Icon Composer document,<Name>.icon, so the harness and Droppy Playground have something to draw. Leave it as it is.droppykit submitleaves the icon document out of the merge request, and the Store's pipeline refuses a merge request that adds or changes one.- Have an idea for it? The merge request has an Icon concept section: a sentence about what it could show, a reference sketch, or an SF Symbol to start from. It is optional, and Jordy reads it.
- Jordy adds the icon to your droplet's folder before it publishes; the publish job refuses a droplet without it. An update keeps the icon the Store already has.
droplet.json still names the document ("icon": "WorldClock.icon"). That is the file name the icon is saved under, so leave it as droppykit new wrote it. The images inside your droplet's own interface, its glyphs and its settings tiles, are yours to make; this is only about the droplet's icon.
The rest of this section is how the icon works, for reading rather than doing.
A .icon is a directory:
WorldClock.icon/
icon.json the document: groups, layers, fills, specializations
Assets/
dial.png every image icon.json names by `image-name`Droppy requires that the document is well formed and self-contained.
What is checked. When an icon document is in your package, droppykit validate checks it the way Droppy will: a .icon directory with a readable icon.json, at least one layer, and every image it names in Assets/. The placeholder passes. When there is none, validate says so and passes too: a submission needs no icon.
What Droppy draws. droppykit build compiles the document with actool, the same compiler Xcode runs on an app's icon, and puts the result in the bundle beside it. That compiled icon is the real macOS one, with the squircle, the material, the specular edge and the shadow, and it is what the Store and the widget picker draw. The harness and older Droppy versions render the document themselves, which is a flat preview of the layers. While you build, that preview shows the placeholder; the published droplet carries Jordy's icon.
Icon and widget style
macOS 26 lets a user draw every app icon Default, Clear or Tinted, each light or dark, in a tint of their choosing (System Settings ▸ Appearance ▸ Icon & widget style), and the Dock follows it live. A Droppy that embeds DroppyKit 1.21.0 draws droplet icons the same way: the system's own icon renderer composites your compiled icon in the user's style, with the real Liquid Glass, and redraws it the moment the setting changes. Your icon needs no change and there is nothing to opt into; it reads the Assets.car droppykit build has written into every bundle since 1.6.2. A bundle built before that carries only the document and stays Default in every style: rebuild it.
What you do control is how your icon looks in the five styles that are not Default. Icon Composer gives every fill a dark and a tinted specialization, and tinted drives both Clear and Tinted:
- Give each layer a
tintedfill, plain white at a chosen strength, so the mono styles have a designed light-to-dim hierarchy instead of whatever the system derives from your colours. - Give the background or the hero a
darkfill where Default would vanish on a dark tile. - Preview all six in Icon Composer before you submit. After
droppykit build, the harness's Overview page draws the built bundle's icon in all six through the same renderer Droppy uses, and Droppy Playground does the same for every droplet you drop in.
A droplet's own views can follow the same choice:
struct MyWidget: View {
@Environment(\.droppyIconStyle) private var pinned
var body: some View {
let style = pinned ?? DroppyIconStyleObserver.shared.style
// style.variant is .default, .clear or .tinted;
// style.appearance is .light or .dark; style.tint is the colour.
...
}
}DroppySystemIconRenderer draws any bundle's icon in any style, and icon(inBundleAt:style:pixelSize:) draws an installed droplet's. Reading the style needs minAPI 1.21.0. On macOS 14 and 15 it is always Default, light or dark with the interface.
The creator avatar
The small round mark beside your droplet's name in the Store and on getdroppy.app/droplets. It says who made the thing at a glance, and it is what separates the Store's Community shelf from its By Droppy shelf visually rather than only by label.
- A square PNG, at least 256×256.
- No rounding of your own. Every surface clips it to a circle; a pre-rounded image is clipped twice and the two radii disagree by a pixel or two all the way around, which reads as a muddy edge rather than a circle.
- It has to read at 20pt. A wordmark will not. A monogram, a face, or a single symbol will.
- It is yours. The blue disc
droppykit newwrites is a placeholder, and the Store's checks refuse it byte for byte.
Transparency is fine. Name it in creator.avatar and place it in Assets/.
"creator": {
"name": "Jane Doe",
"kind": "community",
"url": "https://jane.example",
"avatar": "Creator.png"
}Screenshots are not asked for
The one image you have to make is the creator avatar; the icon is Jordy's. Nothing in the SDK, droppykit validate, the Store repository or review asks for a screenshot, and a droplet that ships none looks complete everywhere: the Store shows its icon, its summary and its real widget, and the page simply has no screenshot strip.
The screenshots key exists for a droplet whose author wants one, and it stays optional. If you use it, name PNGs that sit in Assets/; if you do not, leave the key out entirely.
Test and ship
The harness
Run your droplet in Droppy's Settings panel, without installing it.
DroppyKitHarness opens your droplet in a window that is Droppy's Settings panel: the window chrome, the sidebar rows, the page opener and the content column are the app's own source files, copied into the SDK. What it adds is room — the window is larger than the real one, so a solo and a paired widget fit side by side without scrolling.
It never touches the system. Preferences live in memory, permissions are whatever you toggle, and workspace.open records the URL instead of opening it, so a droplet under test cannot fling Safari at you every time you press its button. That is also why you can run it while Droppy itself is running: the two never fight over shortcuts, the pasteboard, or the notch.
Adding it
One file in an executable target:
import DroppyKit
import DroppyKitHarness
@main
struct WorldClockHarness: DropletHarnessApp {
static func makeDroplet() -> any Droplet { WorldClockDroplet() }
}Important. The file must not be called main.swift. Swift treats that name as top-level code, which cannot coexist with @main.
Running it
droppykit run # or Scripts/run-harness.sh WorldClockHarness
droppykit run --page shelf-widget # open straight onto one surfaceThe harness is built into a real signed .app and launched with open. A bare swift run produces an unbundled binary, and macOS gives one of those fallback window chrome — visibly not the panel you are trying to match.
The pages
| Page | Shows |
|---|---|
| Overview | Identity, icon (and the built icon in all six icon styles), creator, and which surfaces you provide against which you declared |
| Shelf widgets | Every widget solo and paired, at its own declared widths; wrapped around the camera when it opts in; the wash controls to use |
| Live activity | The compact row in a simulated notch, the expanded detail, and the seat |
| Expanded surface | The takeover at the size you return, and the chrome it suppresses |
| Settings pane | Your pane in the real content column |
| Lock screen | Your published row |
| Menu bar | Your extra as a Command Center tile, Small and Medium, and the detail it opens at the panel's width |
| Capabilities | A switch per declared capability, and a warning for undeclared ones |
| Stored values | Every key you have written, with the key Droppy would really use |
| Activity | Every host call and log line, in order |
What it catches
Surfaces you declared but did not implement, and the reverse. The Overview page compares your conformances against droplet.json and names the difference. The two disagreeing is the single most common reason a droplet validates locally and then does nothing once installed.
Refusals. Turn a capability off and the Activity page shows the call being refused, with the capability that was missing — which is exactly what a user who declined a permission will experience.
The notchless case. Toggle Hardware notch off on Overview and every surface re-renders as a floating island. Content that assumes wings either side of a cut-out has nowhere to go, and this is where you find that out.
A row under the camera. A widget that declares notchWrap gets a third stage on the Shelf widgets page: alone on a notched shelf, raised beside the camera, with the ShelfWidgetNotchWrap the host would hand it, computed the host's way for the simulated notch, and the housing outlined over it. A half of the header that strays under the camera shows there, and so does a widget that offsets its row on top of the host's.
The controls to use. Under the stages, the Shelf widgets page draws the wash controls on the shelf's black at every size Droppy uses (the neutral and accent disc at 34, 28 and 24, the pill at 34 and 28), each labelled with the style that draws it. See Design guidelines.
Losing the live-activity seat. Take the seat away from the Live activity page and watch what your droplet does. Losing it is normal; a droplet that keeps working as though it still had it is not.
Pictures of every surface
droppykit run -- --shots ./shots
droppykit run -- --shots ./shots --shot-height 1900--shots renders every harness page to a PNG and exits, without ever putting a window on screen. A relative directory is taken from your package root, so ./shots lands beside Package.swift. The images are of the real views, laid out and drawn by the same code, so they are the pictures a submission wants and the fastest way to compare a change against the previous build. The default height is the harness window's; --shot-height renders a page taller than that in one image instead of cutting it at the fold, which a page with several stages needs.
A headless run stays in the foreground and prints what it wrote, so a script or a coding agent gets the exit status and the file list rather than a window that opened somewhere else.
A verdict a tool can read
droppykit run -- --shots ./shots --report ./shots/report.json
droppykit run -- --report ./report.json--report writes the harness's findings as JSON: the same verdicts the Overview and Capabilities pages draw, for something that cannot read a window. surfaces[] carries declared, implemented and a verdict per surface (provided, missing, undeclared, not-provided); problems lists everything the pages would flag, in words: a manifest that did not decode, an activate(host:) that threw, an id that disagrees between Swift and droplet.json, a widget that leaves a width to the host, artwork that did not load. activity and log are the Activity page, refused calls marked. With --shots the report also lists the images. A droplet is not done while problems has anything in it, which is exactly the rule the coding-agent brief gives an agent; see Build with a coding agent.
What it does not do
It does not simulate Droppy's open and close springs. Those are the app's, and a harness guessing at them would teach the wrong timing. The page header's icon is a flat preview of the icon document, not the composited macOS icon, and while you build that is the scaffold's placeholder: Jordy designs the Store icon (see The icon). The Overview's six icon styles are the real thing, drawn by the system's renderer from the bundle droppykit build wrote into .build, on macOS 26 and later; before the first build, or on an older macOS, the page says so instead.
DropletHarnessApp is the protocol the entry point conforms to. It lives in the DroppyKitHarness module, which ships alongside this one.
Test and ship
Droppy Playground
A free, separate app: the real notch, shelf and island, with only the media player inside, and a way to install droplets. Try one on the real surfaces before buying Droppy, or try the one you are building.
What it is
Droppy Playground is built from the same source as Droppy and draws the same surfaces with the same code: the notch, the expanded shelf, the Dynamic Island, mini HUDs and their cards, the lock screen chips and the menu bar. What it leaves out is everything else Droppy does. There is no file shelf, no clipboard, no Cloud, none of Droppy's own droplets except the media player, and no license.
It is its own app with its own bundle identifier and its own settings, so it installs beside Droppy without touching it. It updates itself the way Droppy does, from Settings, About, and it never updates into Droppy: it reads its own release manifest, and its installer refuses any app that is not the Playground.
URL callbacks
From Playground 1.0.26 and DroppyKit 1.20.0, read host.environment.dropletCallbackURL and append your callback path. Droppy returns droppy://droplet/<id>; Playground returns droppy-playground://droplet/<id>. Both deliver replies through DropletURLHandling to the running droplet. The standalone harness returns nil because it does not register a system URL scheme. Never use isHarness to infer whether callbacks are available. A hardcoded droppy:// URL always targets Droppy, even when your droplet is running in Playground.
What it stands in for
A droplet's minAppVersion names a Droppy release. The Playground has a version line of its own, so it does not compare that number against itself: it answers the handshake with the Droppy version it was built from. A droplet that asks for a newer Droppy than that is refused here exactly as Droppy would refuse it, and the refusal names both numbers.
Installing a droplet
Open the Playground and its Store page appears. Drop a .droplet bundle onto the drop zone, or click the drop zone or Choose to pick one. That is the bundle droppykit build writes into .build/, or one somebody sent you. A folder works too: drop or choose the droplet's package and the bundle in its .build/ is installed. A folder that holds several droplets installs the most recently built one, and the result line says which.
The Playground uses Droppy's native macOS Settings panel: the same sidebar, toolbar, grouped Form pages, native controls, spacing and window behavior. Its Store contains only the Playground's supported surfaces, but the panel chrome and interaction model are shared with Droppy.
The bundle is copied into the Playground's own droplet folder and put through the same admission checks Droppy runs, apart from Droppy's trust check (see below), so a droplet the Playground refuses is one Droppy would refuse too, for the same reason, which the Store row shows. A droplet the Playground accepts is switched on at once.
~/Library/Application Support/Droppy Playground/Droplets/<droplet-id>/<Name>.dropletWhere your droplet shows up
A shelf widget lives on the shelf's Widgets page. Open the shelf (hover the notch, or press Open beside the Display shape switch) and tap the grid button in the floating navigation bar: every widget the Playground can show is there, the media player and each running droplet's widgets under their own icons. Tap yours to open it on the shelf, solo, and add a second to see it paired. Settings, Shelf, Widget icons is the same list, for rearranging and hiding.
The other surfaces need no page. A live activity sits beside the notch as soon as the droplet publishes a state, a HUD appears when it presents one, a lock screen row on the lock surface, a menu bar extra as a tile in the Command Center under Droppy's menu bar icon.
The sidebar has no row per droplet, on purpose: the Playground shows the surfaces droplets land on and nothing else of Droppy's. A droplet's own settings pane opens from its Store row, where the switch, the version and the Remove button also are.
How it differs from Droppy
droppykit build leaves the bundle unsigned. Both apps run with library validation off, so both can load it, and both clear the quarantine flag from a bundle they install. What differs is the trust check. By default, Droppy runs a droplet without asking only when Droppy signed it, which every droplet the Store delivers is; anything else runs after you approve that exact build, under Local droplets on Droppy's Store page or in the question Droppy asks each time it opens. The Playground skips that question and trusts what you install, which is what it is for: the bundle you just built, or one somebody sent you. Every other admission check is the same in both apps.
Running it in Droppy
Release Droppy loads a local .droplet too. On Droppy's Store page, under the Local droplets section, drop the bundle or use Choose to pick it. By default, Droppy asks at every launch whether to run a build it did not sign; DroppyDev remembers the approval instead, and the Playground never asks. Don't ask again for local droplets turns off Droppy's approval questions for all local builds, including new and changed ones. This lets them run with Droppy's permissions while keeping its bundle validity checks. Turn Ask before running local droplets back on under Settings, Store, Local droplets to restore the questions. The droplet host minimum is Droppy 15.3.0.
Seeing both shapes
The Store page has a Display shape switch. Notch draws every surface the way a MacBook with a camera housing draws it: the wings, the cutout to keep clear of, the 32pt shoulders when the shell opens. Island draws every surface as the floating pill every other display gets. A droplet has to look right in both, and this is the fastest way to see whether it does, on a real screen.
Open the shelf from the button beside the switch, or hover the notch or island at the top of the screen, as in Droppy.
A widget that opts in to the notch wrap (DroppyKit 1.21.0) rises beside the camera when it is alone on the shelf, exactly as in Droppy. That needs a Mac with a notch: on any other display the Notch shape has no cutout to wrap around, so the widget keeps its classic layout there, and the harness is where you see the wrapped stage. The row under the switch says which this Mac is.
The new style, on the Store page
From the Playground that embeds DroppyKit 1.21.0, the Store page also shows what the design guidelines ask of a droplet, drawn by the app itself:
- Icon styles: every installed droplet's icon in the six styles of System Settings, Appearance (Default, Clear and Tinted, light and dark), drawn by the same renderer Droppy draws your icon with.
- Widget controls: the flat wash every control in a widget uses, the neutral and accent disc and the accent pill at their sizes, named by the style that draws them. See Design guidelines.
Playground or harness
Use the harness (droppykit run) for the edit-and-run loop: it renders every surface in both shapes at once, in Droppy's own metrics, without installing anything. Use the Playground when you want the droplet on the real surfaces, with the real host arbitrating, and when you want to hand a droplet to somebody who does not have Droppy. See The harness and Host support.
What it cannot tell you
The Playground is Droppy with things taken out, never Droppy with things added, so a surface that does not work here does not work in Droppy either, and one that does, does. What it cannot show is contention: with only the media player inside, your droplet's compact activity is rarely outranked here, while in Droppy a running timer or a recording takes the seat first, and a droplet that joins the persistent set moves to the companion pill beside it. Handle the secondary, surfaceSuppressed and outranked seats anyway; the harness lets you see the last two.
Test and ship
Host support
Which of DroppyKit's surfaces the shipping Droppy actually mounts, and what happens when you reach for one it does not.
Every protocol in this SDK has a stable shape. That is not the same as every protocol having a host behind it. Droppy adopts these surfaces one at a time, and this page is the record of where that has got to, so you can decide what to build before you build it rather than after. It names a Droppy version because the answer moves: a column here is what that release mounts, and a later one never mounts less.
Droppy 16.0.1 and Playground 1.0.27 embed DroppyKit 1.20.1. This includes 1.19.0's confirmation dialogs, dynamic shelf layout invalidations, host-specific callback URLs and 1.20.1's widget insets: one even gap on the Dynamic Island, and corner clearance under the 18pt card corner in contentInsets. Update the host before installing a droplet whose kit.minAPI requires these versions. A newer SDK package alone does not update the framework embedded in an installed app. DroppyKit 1.21.0 (the notch wrap, the wash controls and the icon style) arrives with the next Droppy and Playground release; a droplet that declares it is refused by every host before that one.
The rule the host follows is the same everywhere: a surface Droppy cannot mount refuses, and says so. It never silently succeeds. A refused call returns false or nil and writes one line into Droppy's log naming what it refused and why, which you can read with:
log stream --predicate 'subsystem == "app.getdroppy.Droppy" AND category == "droplets"'The same line works for Droppy Playground, which logs under Droppy's subsystem.
A droplet that believes it put something on screen behaves worse than one that knows it did not, which is why nothing here fails open.
The matrix
| Surface | Protocol | Droppy 15.3 |
|---|---|---|
| Preferences | DropletPreferencesService | Mounted |
| Environment | DropletEnvironmentService | Mounted |
| Logging | DropletLogService | Mounted |
| Install state | DropletInstallStateService | Mounted |
| Workspace | DropletWorkspaceService | Mounted |
| Feedback | DropletFeedbackService | Mounted |
| Permissions | DropletPermissionsService | Mounted |
| Global shortcuts | DropletShortcutsService | Mounted |
| Mini HUD | DropletHUDService | Mounted |
| Shelf open and close | DropletShelfService | Mounted |
| Live activity seat | DropletLiveActivityService | Mounted |
| Live activities | LiveActivityProviding | Mounted |
| Lock screen rows | LockScreenStatusProviding | Mounted |
| Menu bar extras | MenuBarExtraProviding | Mounted |
| Shelf widgets | ShelfWidgetProviding | Mounted |
| Expanded surfaces | ExpandedSurfaceProviding | Mounted |
| Settings panes | SettingsPaneProviding | Mounted |
| Settings pages | SettingsPagesProviding | Mounted (DroppyKit 1.13.0) |
| Settings page toolbar | makeSettingsPageToolbar(id:context:) | Mounted (DroppyKit 1.18.0) |
URLs (droppy://droplet/<id>/…) | DropletURLHandling | Mounted (DroppyKit 1.11.0) |
| Launcher bridge | DropletLauncherService | Mounted (DroppyKit 1.12.0) |
Minimum macOS (minMacOS) | minMacOS | Enforced (DroppyKit 1.12.0) |
Getting a droplet onto a Mac
Droppy loads every droplet it finds under
~/Library/Application Support/Droppy/Droplets/<droplet-id>/<Name>.dropletat launch, one directory per droplet, named for the id in its manifest. The folder, the bundle identifier and the manifest id are cross-checked against each other, so a renamed folder is refused rather than loaded under the wrong identity.
That is where the Store puts an accepted droplet too. Switching a Community row on downloads the signed bundle from the catalogue, checks its hash and signature, copies it into that folder and loads it; the droppy://extension/<id> deep link on getdroppy.app opens the same row inside Droppy, and a newer published version is offered as an update when the catalogue refreshes. Before the first droplet from another maker is installed, Droppy tells the user once that such droplets can be unreliable. In Droppy Playground the folder is ~/Library/Application Support/Droppy Playground/Droplets/.
While you are developing, droppykit build writes the bundle into .build/ unsigned, and Droppy runs that too. On Droppy's Store page, under Local droplets, drop the bundle or the droplet's folder, or click to choose either. A droplet runs inside Droppy with Droppy's permissions. By default, Droppy asks before running a build it did not sign, and that answer lasts until Droppy quits. Each time Droppy opens it asks again, once, in one question that names every switched-on droplet it did not sign. These approvals stay in memory; release Droppy never reads persistent per-build approvals. A droplet signed by Droppy, which is every droplet the Store delivers, never asks.
Choose Don't ask again for local droplets to stop approval questions for all local builds, including new and changed ones, across restarts. This is broad permission to run local code with Droppy's permissions, stored in your settings, not an authenticated approval of each build. Turn Ask before running local droplets back on under Settings, Store, Local droplets to restore the questions for future loads and launches. Code that already loaded keeps running until Droppy quits.
The approval covers everything inside the bundle, its code and its resources alike, because a droplet can run a file it reads from its own bundle. While approval questions are enabled, a bundle changed on disk after you approved it is refused until you approve it again, so keep anything your droplet writes out of its bundle. Even with questions disabled, a build that loads a library from outside the bundle (anything other than the system's own libraries and the DroppyKit that Droppy ships), or that holds a symbolic link with an absolute target, is refused rather than approved. A rebuild of a droplet that has already run since Droppy started is installed, but the earlier build keeps running until you restart Droppy, because macOS cannot unload code a running app has loaded. Adding the build that is already running again needs no restart.
Droppy Playground, the free separate app, takes the same bundle onto the same surfaces without asking, so it is where somebody without Droppy tries one; see Droppy Playground. Use the harness for the edit-run loop (see The harness).
What "mounted" means
Your droplet's call reaches Droppy's own surface and the user sees the result. A refusal from a mounted surface is a real refusal (a missing capability, a higher band already owning the notch), never a missing implementation.
How the compact seat is awarded
Droppy's own activities (media, timers, recordings, meetings, the calendar ring) are arbitrated among themselves. A droplet's activity never takes the notch from one of them on its own: it holds the compact seat when none of them want the notch, and its wings stand down the instant one does. Among droplets, the highest priority wins.
Standing down is not disappearing when your state sets joinsPersistentActivitySet: while one of Droppy's activities owns the notch, yours rides the round companion pill beside it, after Droppy's own companions, drawn by makeCompanionCompact(context:). The context's slotSize is the square Droppy's own companions draw their mark in, with the same padding around it; the host scales a larger mark down to it and clips to it, so draw for that square, not for a wing. The pill behaves like Droppy's own: a tap opens the shelf on your widget (expandedWidgetID, or your first widget) and moves your activity into the notch when the shelf closes, and a horizontal flick swaps it with the activity that owns the notch, which then rides the pill until it is flicked back.
DropletLiveActivityService tells you which seat you hold, and the reasons are exact: compact while your row is drawn, secondary while your pill is, outranked when another droplet's priority beat yours, surfaceSuppressed while Droppy's own surfaces own the notch and no pill is up, idle when you published nothing. The row is compact only, like Droppy's own activities: hovering or clicking it opens the shelf, and makeExpanded(context:) is not mounted. Opening the shelf hides the row; use a shelf widget for controls. The activity has to be declared under surfaces in droplet.json; a droplet that conforms without declaring it is not mounted, and the harness says so.
Drawing against the right shell
The shell is not one shape at two sizes. Opening it re-radiuses both ends, and the content insets differ by mode by more than the width of most controls:
| Compact (closed notch, strip) | Card (expanded HUD) | Expanded shelf | Island | |
|---|---|---|---|---|
| Top shoulders | 9, outward | 32 | 32 | n/a |
| Bottom corners | 12 | 28 | 40 | 18 closed, 44 open |
| Width on a notch | cutout + 70 a side | cutout + 120 a side | content + insets | 150 / 240 |
| Content inset, top | n/a | 37 | 47 | 16 |
| Content inset, sides | 22 | 48 | 48 | 12 compact, 16 open |
The 37 and 47 are the camera housing plus, for the shelf, a 10pt gap. Content drawn across that band on a notched Mac is not clipped and logs nothing: it is simply behind the housing, and only on the machines that have one.
A shelf widget's rectangle sits inside those insets with a 1.5pt slot gutter on every edge, so the numbers ShelfWidgetLayoutTraits declares are the widget's and the shell is derived from them:
| Notch | Island | |
|---|---|---|
| Solo shell | widget width + 3 + 96, never under 451 | widget width + 3 + 32, never under 405 |
| Smallest solo widget | 352 | 370 |
| Standard height | 93 | 93 |
| Fixed height | 48 through 480, then + 3 gutter | same |
| Background | none: content sits on the shell, like Droppy's own widgets | same |
| Clip corner | 18 alone; square in a row or wrapped | square in a row; alone, none (the island shell clips it) |
Content inset (contentInsets) | 8 alone; 0 in a row or wrapped | 0 |
| Grouped card height | declared height − 16 | declared height − 16 |
Notch wrap (notchWrap) | alone, opted in, a real notch, bands wide enough | never |
A takeover is sized the same way: ExpandedSurfaceSizeProposal and availableSize are the content area, and the host adds the same chrome around it.
DroppyShellMetrics carries every number above, derived the way the host derives them, and DroppyNotchShellShape is the silhouette itself. symmetricPadding(iconSize:notchHeight:usesNotchStyle:) is the one padding every compact surface uses: 22 on a notch against 12 on an island, because the concave shoulder carves into the drawable area and a convex capsule does not.
You rarely need these directly (the host insets your view for you), but a compact accessory that adds padding of its own lands somewhere different in each mode. A shelf widget keeps clear of its clip corner with contentInsets, which is the clearance for whichever corner the host is drawing (none on a solo island card, which the island shell clips), so it needs a radius only to round artwork of its own on that card, at shelfFullBleedCornerRadius.
The HUD's two shapes
A HUD can be a strip or a card. A strip is the at-rest shape: the width of the notch plus a wing either side, no interaction, clicks falling through to whatever is underneath. A card is what Droppy's own low-battery and AirPods HUDs grow into: the interactive width, a title, a description, controls.
Both are the same HUD. Re-present with the SAME id and isExpanded: true, and the host morphs the strip into the card on its own spring; a second request with an id already on screen replaces the first in place rather than queueing behind it. See DropletHUDRequest.
A droplet HUD is mounted exactly like Droppy's own battery HUD and runs through the same HUD manager, so its appearance, the strip-to-card morph and its close are Droppy's own motion. Neither view you hand the host takes an entrance transition of its own. Content you swap inside a strip or a card that is already up uses DroppyTransition.
Keeping a menu bar extra current
Droppy shows every droplet's menu bar extra as a tile in its Command Center, the panel under Droppy's one menu bar icon, and owns that tile, so a droplet never creates an NSStatusItem of its own: Droppy cannot order, hide or remove an item it did not create, and one a droplet's teardown misses stays in the menu bar until Droppy quits. Return the extra from makeMenuBarExtra() instead, and nil while there should be none. The first time a droplet offers an extra, Droppy puts its tile on a Command Center page; a press opens the extra's content as a detail over the page.
Droppy calls it again when the droplet starts, stops, is updated or is removed, each time the content opens, and after the droplet broadcasts a state change: its objectWillChange fires, one of its preference keys changes, an @Observable property it read changes, or statePublisher fires. A burst of broadcasts costs one call, at most about four a second, and the tile is redrawn only when the symbol or the title changed. The content is built each time it opens, and a detail that is open when the droplet stops lets go of the droplet's views at once. Droppy 16.0.1 and earlier read the extra only when the droplet started, stopped, was updated or was removed, and Droppy before the Command Center showed it as a status item of its own.
Keeping widgets and expanded surfaces current
The same broadcasts reach the shelf. A state change is any of these: the droplet's objectWillChange fires (when it is an ObservableObject), one of its preference keys changes, an @Observable property read during the previous call changes (when it is not an ObservableObject), or statePublisher fires. A burst costs one read, at most about four a second, and a read that changed nothing changes nothing on screen.
- Shelf widgets. Droppy reads
widgetDescriptorswhen the droplet starts, stops, is updated or is removed, after every state change, and oninvalidateLayout(for:). A widget the droplet starts declaring appears in the widget picker and Settings; one it stops declaring leaves the shelf with the user's placement kept, so it returns to the same slot; a new title, symbol, width or height applies at once. See Shelf widgets. - Expanded surfaces. While a surface is up, Droppy asks
expandedSurfaceSize(_:fitting:)again after every state change, with the shelf's last proposal, and re-lays the shelf out when the answer changed. The descriptor, its suppressions included, is read when the surface is presented and kept until it is dismissed, so a change applies on the next presentation. A broadcast never dismisses a surface. See Expanded surfaces.
Droppy hears the droplet object. State held by another object, a model or a session the droplet owns, reaches it only by forwarding that object's objectWillChange to the droplet's own, or by being stored in preferences; @Observable state is heard wherever it lives, but only for a droplet that is not itself an ObservableObject.
Droppy 16.0.1 and earlier read widget descriptors only when the droplet started, stopped, was updated or was removed, or called invalidateLayout(for:), and asked a surface's size only on layout passes the shelf made for its own reasons.
Install state
activeWidgetIDs lists the droplet's widgets on the user's Custom Shelf, and only widgets the droplet currently declares. A widget the user adds in the shelf editor joins at once; one they remove there leaves when they click Done, since a Cancel puts it back. statePublisher sends a change when the user places or removes one, never a value equal to the last. isEnabled is true while the droplet runs and turns false just before the user switches the droplet off or removes it. An update to the droplet is not a switch-off and sends nothing.
A change is also a state change the shelf follows, so it brings one read of the droplet's widgets, menu bar extra and expanded surface. A read that changes no widget changes no placement, so it sends nothing further.
Droppy 16.0.1 and earlier never send a change: isEnabled stays true and activeWidgetIDs stays empty.
Developing against them
The harness renders every one of these: shelf widgets in both the solo and grouped compositions, expanded surfaces at their real sizes, live activities in both seats, HUDs in both shapes, and settings panes in Droppy's own Settings chrome. A droplet that looks right in the harness looks right in Droppy, because the harness draws with the same design system and the same metrics the app does. See The harness.
What the harness does not simulate is arbitration: which droplet wins the compact seat, what happens when a HUD arrives while Droppy's own owns the notch, what the shelf does when the user collapses it under a takeover. Those are host decisions, and the honest way to develop against them is to handle a refusal rather than to assume one never comes.
Checking from inside a droplet
Nothing in the SDK reports "is this surface mounted", deliberately: a droplet that branches on host capability ages badly, because the branch is still there long after the capability arrives. Handle a refusal instead, that code stays correct in both worlds:
if !host.hud.present(request) {
// Not an error. The surface was taken, or this Droppy does not
// mount it. Either way the moment has passed; do not retry.
host.log.debug("HUD not shown")
}Use isGranted(_:) to hide affordances for a capability the user did not grant. That is a different question from whether the host mounts the surface, and it is the one worth asking in your own UI.
Related
Test and ship
Submitting
Get your droplet into Droppy's Store.
The Store is a repository: gitlab.com/droppyformac1/droplets, one folder per droplet. You open a merge request that adds your folder, a maintainer reads the code and merges it, and the pipeline builds it, signs it with Droppy's Developer ID and puts it on the shelf. Every Mac running Droppy can install it within minutes of the merge, with no app release in between. An update is the same thing again with a higher version.
Why open source
A droplet is native code in Droppy's process, with Droppy's permissions. There is no sandbox between the two — which is what lets a droplet draw real SwiftUI on the notch instead of shipping a JSON description of a layout.
The price of that is review, and review means readable code. The merge request is the review: what merges is what ships, from the commit that was read.
Your licence is yours. The SDK's licence covers the SDK; it claims nothing over what you build with it. Name yours in droplet.json under source.license.
Before you submit
droppykit validateThis is the same check the pipeline runs. It verifies:
droplet.jsondecodes and satisfiesvalidate(submission:)- the id is lowercase, valid, and matches
idand the Info.plist - every declared surface has a matching conformance
- every capability is one DroppyKit knows
iconnames an Icon Composer document, and the scaffold's placeholder, when it is there, is well formed (the Store icon is Jordy's; see below)- the creator avatar is a square PNG of at least 256×256
- the built bundle links the resilient framework, and carries no direct-access imports — see Add the package
The repository adds a few rules of its own, all in its CONTRIBUTING.md: the folder is named after the id, source.repository is your folder's page in that repository, creator.gitlab is your GitLab username (it generates CODEOWNERS, which is what gets you asked before someone else changes your droplet), CHANGELOG.md starts with an entry for the version being submitted, and Package.swift depends on DroppyKit alone. droppykit submit fills in the first two for you.
Replace everything the scaffold filled in, except the icon. droppykit new leaves placeholders so the package builds and validates from the first minute, and a submission that still carries one is refused by the Store's checks or sent back in review:
Assets/Creator.png: your own mark, not the blue disc;summary,description,categoryandcreatorindroplet.json, andidwhile it can still change (it is permanent from the first publish);CHANGELOG.md: a first entry that says what the droplet does, never only "First release.";README.md: what the droplet does and how to use it, for the person deciding whether to install it.
The icon placeholder stays as droppykit new wrote it: Jordy makes the icon (next section).
Send only what the Store takes. The folder holds what the repository's CONTRIBUTING.md lists: droplet.json, Package.swift, Sources/, Assets/, CHANGELOG.md and README.md, and the icon document once Jordy has added it. No Tests/ folder or test target, no scripts or install helpers, and no build-tool plugins, macros, binary targets, executables besides the harness, or unsafeFlags in Package.swift. droppykit submit leaves the icon, your .gitignore, Package.resolved, build products, shots and agent files behind on its own.
What it does not ask for is screenshots, though it welcomes them: name up to eight PNGs in Assets/ under screenshots and the Store shows them on your droplet's page before it is installed.
Jordy makes the icon
Do not make your droplet's icon. Jordy designs every Store droplet's icon himself, in Icon Composer and in Droppy's current icon style, so each one adapts properly to light, dark, clear and tinted icons and matches the rest of the Store. Your merge request carries no icon: droppykit submit leaves the scaffold's placeholder out, and the pipeline refuses an icon in anyone's commit but Jordy's. If you have an idea for it, describe it in the merge request's Icon concept section: a sentence, a reference sketch, or an SF Symbol to start from. Jordy adds the icon before the droplet publishes. See The icon.
Submitting
brew install glab && glab auth login # once
droppykit submitIt forks the Store repository (once), copies your package into droplets/<id>/ (everything but the icon), generates CODEOWNERS, commits on a branch droplet/<id>-<version>, pushes to your fork and opens the merge request with the checklist filled in and an empty Icon concept section. Read it over, tick what applies, add an icon idea if you have one, and it is submitted. droppykit submit --print shows what it would do without doing it.
You can do the same by hand: fork the repository, add your folder under droplets/ without its .icon document, run scripts/codeowners.py, and open a merge request against main. The pipeline validates the folder the moment the merge request opens.
Review
A maintainer reads the code and runs the droplet. What sends a merge request back, most often first:
- Capabilities you do not use. The user sees the list before installing. Ask for what you use and nothing else.
- Design that ignores the surface. Light backgrounds on dark chrome, Title Case labels, borders, invented spacing. See Design guidelines.
- Work that does not stop. A timer or observer that survives
deactivate(). Swift cannot unload code, so it runs until Droppy relaunches. - A live activity that never yields. Holding the compact seat when there is nothing to show.
- A droplet that is not a good citizen. Work while nothing is visible, blocking calls on the main actor, defaults that take something over, or secrets in preferences. See Good citizen.
None of these are rejections. They come back as a comment on the merge request, and a new commit on the same branch usually settles it.
Publishing
Once merged, the pipeline builds the folder universal against the framework Droppy actually ships and signs it with Droppy's Developer ID. It is not notarized: Droppy downloads it itself and strips the quarantine flag before loading, so there is no Gatekeeper pass for a notarization to satisfy.
The Store sorts droplets by maker, All droplets / By Droppy / Community, and every row carries its creator's mark, so a published droplet is listed beside your name and your avatar. The row's switch is the install: Droppy downloads the signed bundle from the catalogue, checks its hash and signature, copies it into
~/Library/Application Support/Droppy/Droplets/<your-id>/<YourDroplet>.dropletand loads it, running the same admission checks described in Host support. Before the first droplet from another maker is installed, Droppy tells the user once that such droplets can be unreliable. The droppy://extension/<your-id> deep link on getdroppy.app opens your Store page inside Droppy, the Source code row on that page opens your folder in the repository, and a newer published version is offered as an update when the catalogue refreshes, which it does every six hours and every time the Store opens.
Versions
version is major.minor.patch, numeric only. A pre-release tag cannot be published: the fleet's catalogue decode is strict, and one undecodable entry refuses the whole catalogue.
Raise it on every merge request. The pipeline publishes only folders whose version is newer than the shelf's, and the Store compares versions to decide who needs an update. The text under the newest CHANGELOG.md heading is what the Update button shows.
Your responsibility
Opening a merge request means accepting the Droplet developer terms (version 2026-09-17) at getdroppy.app/droplet-developer-terms; the merge request template says so and asks you to confirm it. You stay responsible for your droplet: its bugs, and how it handles user data. Droppy reviews the merged commit and signs the build, so someone has read the code before it ships. That review and signature do not mean Droppy vouches for the droplet or maintains it; it remains yours. Droppy shows users a one-time notice that droplets from other makers can be unreliable before their first Store community install or first local install.
Reference
API reference
Every public type in DroppyKit, 172 of them.
Every public type in DroppyKit, 172 of them, grouped by what they are for. Each one has its own page on the site.
The droplet
What every droplet is built out of.
- Droplet — The runtime object representing one droplet inside the host process.
- DropletID — Stable identifier of a droplet across the whole system.
- DropletHost — Everything the host offers a droplet.
- DropletURLHandling — Adopt to receive URLs opened at droppy://droplet/<your-id>/….
- DropletURLRequest — A URL Droppy received on a droplet's behalf.
Shelf widgets
A card on the expanded shelf, alone or sharing the row.
- ShelfWidgetProviding — Adopt to contribute one or more widgets to the notch shelf.
- ShelfWidgetDynamicLayoutProviding — Optional live-layout refinement for a shelf widget provider.
- ShelfWidgetDescriptor — Static description of one shelf widget, rendered by the host without calling into the widget's views.
- ShelfWidgetContext — Context passed to widget view factories on every mount.
- ShelfWidgetID — Stable identifier of one shelf widget a droplet provides.
- ShelfWidgetLayoutTraits — Declarative layout parameters for one shelf widget.
- ShelfWidgetContentHeight — How tall the widget's card should be inside the shelf.
- ShelfWidgetFocusPolicy — Whether the widget participates in shelf keyboard focus.
- ShelfWidgetPlacement — A widget's horizontal position within its shelf row.
- ShelfWidgetPrewarmPolicy — Whether the shelf may mount the widget invisibly ahead of time.
- ShelfWidgetNotchWrapTraits — How a widget's header row wraps around the camera, declared in notchWrap.
- ShelfWidgetNotchWrap — The bands beside the camera for a widget the host wrapped around the notch, handed down in notchWrap.
Live activities
A compact row beside the notch, and its detail.
- LiveActivityProviding — Adopt to show a compact live activity in the notch or island: small leading and trailing accessories beside the (simulated) notch.
- LiveActivityState — State of a droplet's compact live activity.
- LiveActivityContext — Structure
- LiveActivityPresentationState — Context passed to makeExpanded(context:).
- CompactLiveActivityContext — Context passed to the seat-aware compact factories (DroppyKit 0.7.0).
- CompactLiveActivityPresentationMetadata — Opaque host-facing metadata for a compact live activity's leading accessory.
Expanded surfaces
A takeover on the notch.
- ExpandedSurfaceProviding — A provider of expanded surfaces: content, size, and the far end of the host's lifecycle.
- ExpandedSurfaceHosting — Adopt to take the expanded shelf over with your own content.
- ExpandedSurfaceDescriptor — Static description of one expanded surface, readable by the host without mounting anything.
- ExpandedSurfaceContext — What the host tells expanded-surface content about its mount.
- ExpandedSurfaceID — Stable identifier of one expanded surface a droplet provides.
- ExpandedSurfacePresentation — One live presentation of an expanded surface, as the host records it.
- ExpandedSurfacePresentationRequest — A request to take the expanded shelf over with one of your surfaces.
- ExpandedSurfaceSizeProposal — What the host offers before asking a droplet how big its surface wants to be.
- ExpandedSurfaceHostSuppression — Which of the host's own expanded-shelf behaviors stand down while a takeover is on screen.
- ExpandedSurfaceDismissalReason — Why an expanded surface stopped being presented.
Other surfaces
Settings, the lock screen, the menu bar, HUDs.
- SettingsPaneProviding — Adopt to contribute a settings pane to the Droplets shop detail page and to embedded settings surfaces.
- SettingsPaneContext — Context passed to makeSettingsPane(context:).
- SettingsPanePresentation — Where a settings pane is being presented.
- SettingsPagesProviding — Adopt to give your droplet's Settings page pages of its own (DroppyKit 1.13.0).
- DropletSettingsPageLink — A row that opens one of your droplet's own Settings pages (DroppyKit 1.13.0).
- DropletSettingsPageIcon — A link row's tile from an image: the image at the tile's size, with high quality interpolation, or nothing when there is none (DroppyKit 1.13.0).
- DropletSettingsPage — The root of one of your droplet's own Settings pages (DroppyKit 1.13.0).
- DropletSettingsPageOpener — How a DropletSettingsPageLink asks the host for a page (DroppyKit 1.13.0).
- HUDPresenting — Marker capability for droplets that present transient HUDs.
- LockScreenStatusProviding — Adopt to publish a status entry on Droppy's lock screen surface.
- LockScreenStatusEntry — One entry in the lock screen status widget strip.
- MenuBarExtraProviding — Adopt to contribute a menu bar extra.
- MenuBarExtraDescriptor — Static description of a menu bar extra a droplet contributes.
- DropletSurfaceKind — The kind of surface a droplet view is currently rendered on.
Host services
Everything a droplet can ask Droppy to do.
- DropletPreferencesService — Persistent, droplet-scoped settings.
- DropletEnvironmentService — Read-only facts about the host and the machine it is running on.
- DropletHUDService — Presents transient HUDs on the notch surface.
- DropletHUDRequest — A transient HUD to show in the notch.
- DropletHUDPriority — How loudly a HUD asks for attention.
- DropletShelfService — Opens and closes Droppy's shelf, and reveals this droplet's widgets in it.
- DropletLiveActivityService — Drives this droplet's live activity.
- DropletLiveActivitySeat — Where a droplet's activity currently sits (DroppyKit 0.6.0).
- DropletLiveActivityYieldReason — Why a droplet's activity is not holding the compact slot (DroppyKit 0.6.0).
- DropletNotchSurfaceService — Presents and dismisses expanded (takeover) surfaces on the notch.
- DropletShortcutsService — Registers global keyboard shortcuts the user can rebind in Droppy's Settings, Shortcuts page.
- DropletKeyboardShortcut — A key equivalent, as the user recorded it.
- ShortcutsProviding — A droplet whose own shortcuts Droppy lists on its Shortcuts page, in a section with the droplet's name and icon.
- DropletShortcutEntry — One shortcut a droplet keeps itself, listed on Droppy's Shortcuts page.
- DropletWorkspaceService — Things a droplet can ask the system to do on the user's behalf.
- DropletFeedbackService — Plays Droppy's own sound and haptic feedback, so a droplet feels like the rest of the app instead of inventing its own vocabulary.
- DropletFeedbackCue — One of the host's own feedback cues.
- DropletPermissionsService — Checks and requests the macOS permissions behind a capability.
- DropletPermissionStatus — Whether the host currently holds a system permission.
- DropletInstallStateService — Reports what the user has switched on, so a droplet can avoid doing work for a widget nobody is showing.
- DropletInstallState — Which of this droplet's pieces the user currently has switched on.
- DropletLogService — Writes into Droppy's own log stream, tagged with the droplet id.
- DropletLogLevel — Severity of a droplet log line.
- DropletNotchGeometry — How the notch surface is physically shaped on one display.
- DropletExpandedShelfState — What the host's expanded shelf is currently showing.
- DropletSurfaceAppearance — The appearance Droppy is drawing its surfaces in.
- DroppyKitHostBridge — Extension points a host process installs into DroppyKit at launch.
Launchers
Droppy's commands, droplets, settings and content, for a launcher.
- DropletLauncherService — Droppy's commands, settings, droplets and content, for a droplet that draws a launcher.
- DropletUnavailableLauncherService — The service a host without a launcher bridge, or a droplet without the launcher capability, is given.
- DropletLauncherItem — One thing Droppy offers a launcher: a command, a settings page, a droplet, a note, a clipboard entry, a web answer.
- DropletLauncherIcon — How a launcher draws an item's icon.
- DropletLauncherAction — Something that can be done with an item.
- DropletLauncherOutcome — What the launcher does after the host ran an action.
- DropletLauncherDetailContext — What a detail surface is given by the launcher that shows it.
- DroppyPanelSurface — Droppy's own launcher surface, for a droplet that draws a floating panel of its own: a launcher, a palette, a picker.
- DroppyKitSurfaceBridge — Where DroppyPanelSurface gets the host's own surface.
- DroppyShortcutRecorder — Droppy's own shortcut recorder, for a droplet that keeps shortcuts of its own (DroppyKit 1.14.0).
- DroppyPanelActivation — Droppy made the active app while a droplet's own floating panel is up, and the foreground handed back when it closes (DroppyKit 1.15.0).
Tools for AI apps
Actions an AI app can run through Droppy's MCP server.
- MCPToolProviding — Adopt to offer typed tools to AI apps through Droppy's MCP server (DroppyKit 1.21.0).
- DropletMCPTool — One action a droplet offers to AI apps through Droppy's MCP server.
Manifest and bundle
What ships, and what the host reads before it loads.
- DropletManifest — The contents of a droplet's droplet.json.
- DropletManifestError — Why a droplet.json was refused.
- DropletCreator — Who made a droplet.
- DropletCreatorAvatar — The rules a creator avatar has to satisfy.
- DropletCategory — The shelf of the Store a droplet sits on.
- DropletSource — Where a droplet's source lives.
- DropletSurface — A surface a droplet says it provides.
- DropletKitRequirement — Which DroppyKit a droplet was built against.
- DropletPermission — Capability strings a .droplet declares under DroppyPermissions in its Info.plist.
- DropletIcon — The rules a droplet's icon has to satisfy.
- DropletIconRenderer — Enumeration
- DropletIconError — Why an Icon Composer document was refused.
- DropletIconValidation — Checks an Icon Composer document without rendering it.
- DroppyIconStyle — One of the six ways macOS 26 and later draws an app icon, plus the tint.
- DroppyIconStyleObserver — The user's icon style, kept current.
- DroppySystemIconRenderer — Draws an icon the way the Dock does, in any DroppyIconStyle, through the system's own icon renderer.
- DropletBundleInfo — Parsed metadata of one .droplet bundle, read from its Info.plist without loading any code.
- DropletBundleInfoError — Errors thrown while reading a .droplet's metadata.
- DropletInfoPlistKey — Info.plist keys of a .droplet bundle.
Surface geometry
The shapes and sizes Droppy draws its surfaces at.
- DroppyNotchShellShape — The U-shaped shell Droppy's notch surface is drawn in.
- DroppyShellMetrics — The geometry Droppy draws its surfaces at.
Design system
Droppy's own tokens, rows and controls.
- AdaptiveColors — Semantic color palette for droplet UI.
- DroppySpacing — Standardized spacing values on a 4pt grid.
- DroppyRadius — Standardized corner radius values (tiered radii standard).
- DroppyOpacity — Standardized opacity values for consistent layering.
- DroppyAnimation — Droppy's named animation presets.
- DroppyKitMotionBridge — Host-installed motion hooks.
- DroppyTransition — The content transitions Droppy's own notch surfaces use (DroppyKit 1.8.0).
- DroppyLiveActivityMetrics — Native metrics for compact and interactive live-activity rows.
- DroppySettingsControlMetrics — Shared metric constants for settings rows.
- DroppySettingsLayoutMetrics — Shared layout metrics for settings rows and cards.
- DroppyAccentButtonStyle — The accent pill for a primary action: Liquid Glass with the accent washed over it (0.18 at rest, 0.24 under the pointer, 0.30 pressed) and the adaptive label ink, exactly the host's DroppyAccentButtonStyle.
- DroppyQuietButtonStyle — The quiet pill for a secondary action: plain Liquid Glass with the adaptive label ink, the host's DroppyPillButtonStyle.
- DroppyCircleButtonStyle — The circular icon button for an icon-only action: the Liquid Glass disc the File Tray puts on an item (remove, pin) and the shelf's floating close chip, the host's DroppyCircleButtonStyle.
- DroppyButtonChrome — Neutral chrome fill shared by Droppy's quiet filled control family.
- DroppyButtonSize — Size variants of the Droppy button family.
- DroppyGlassControl — The Liquid Glass control recipe: the fill vocabulary, the ink, and the wash for one state.
- DroppyGlassControlChrome — The Liquid Glass control as a modifier, so a style with its own sizing (the pill family, the disc) and a Menu label (which cannot take a ButtonStyle) draw the identical control.
- DroppyGlassButtonStyle — The glass control as a button style for labels that size themselves: shape and fill in, the ink from the fill unless the label sets its own (an accent glyph, red for a destructive action).
- DroppyLiveActivityControlStyle — A 24pt circular control matching Droppy's collapsed media interaction.
- DroppyLiveActivityControlProminence — Visual prominence for an inline live-activity control.
- DroppyWashCircleButtonStyle — The circular control of Droppy's timer widgets: a flat wash, a semibold glyph, and the 0.94 press.
- DroppyWashPillButtonStyle — The labelled control of Droppy's timer widgets: Pomodoro's Start Timer pill and its Focus and Break pills, a flat wash under rounded semibold type.
- DroppyWashTone — What a wash control carries: nothing but the white, or a colour.
- DropletSettingsPane — The root of a droplet's settings pane (DroppyKit 1.9.0).
- DropletSettingsSection — A titled group of rows inside a DropletSettingsPane (DroppyKit 1.9.0).
- DropletSettingsCard — Card wrapper for grouping settings rows.
- DropletSettingsDivider — Divider between ordinary rows inside a DropletSettingsCard.
- DropletControlRow — Base settings row: title with optional icon and info tip on the left, any accessory control on the right.
- DropletToggleRow — Standard toggle row: title and info tip on the left, small switch on the right.
- DropletMiniToggleRow — Compact nested toggle row for stacked rules and reorder lists.
- DropletStackedRow — Row whose content stacks beneath the title block instead of trailing on the right.
- DropletSliderRow — Standard slider row: title on the left, slider and click-to-type value pill on the right, on one line like a System Settings slider.
- DropletGroupedPickerRow — Title and info tip above a picker, inset evenly inside the native card.
- DropletValuePill — The read-only value of a settings row.
- DropletSettingsToolbarPill — Droppy's Liquid Glass pill for the Settings toolbar, drawn by the host (DroppyKit 1.18.0).
- DropletSettingsToolbarButton — One action of a page, as a pill in Droppy's Settings toolbar (DroppyKit 1.18.0): "New item", "Add application".
- DropletSettingsToolbarMenu — A pill in Droppy's Settings toolbar that opens a menu (DroppyKit 1.18.0): "Add" with what can be added, a sort order, or what can be done to the page's subject as a whole.
- DropletSettingsToolbarMenuItem — One row of a DropletSettingsToolbarMenu (DroppyKit 1.18.0): an action, a separator, or a small section title.
- DropletSettingsToolbarSearchField — A search over the page, as a pill in Droppy's Settings toolbar (DroppyKit 1.18.0).
- DropletSettingsToolbarSwitch — A page's own on/off switch, as a pill in Droppy's Settings toolbar (DroppyKit 1.18.0), where every feature page of Droppy keeps its own.
- DropletSettingsToolbarMetrics — The numbers Droppy draws its Settings toolbar pills with (DroppyKit 1.18.0).
- DropletConfirmationDialog — A confirmation drawn by Droppy using its own glass dialog (DroppyKit 1.19.0).
- SettingsUnifiedSegmentedRow — One value out of a short list, as the native pop-up button a System Settings row carries: the current choice with the up/down chevrons, a checkmarked menu of every option, keyboard and VoiceOver behaviour courtesy of AppKit.
- SettingsPickerOption — One option of a segmented settings picker.
- SettingsGroupPosition — Vertical position of a grouped row within its parent DropletSettingsCard.
- SettingsSelectionChrome — One rule for "this is the option you picked", across every selectable control DroppyKit publishes.
- SettingsAdaptiveColor — Builds a Color that resolves per appearance, so a token can be written as a (ColorScheme) -> Color function and still be read at a call site with no ColorScheme in scope.
- SettingsInfoTip — Lightweight info button that shows a text popover on hover.
- SettingsSearchEntry — One entry the host's settings search can surface for a droplet.
- GroupedCorners — Describes which corners of a cell round when composing grouped rows, where outer corners follow the parent card's curvature and inner edges stay flat.
- DroppyKitThemeBridge — Enumeration
- DroppyKitThemeResolution — Host-installed theme hooks.
- DroppyMarkdownEditor — Droppy's markdown editing surface.
- DroppyMarkdownEditorCommands — A handle onto the host editor's command bus (DroppyKit 0.2.2).
- DroppyMarkdownEditorContext — Everything the host editor needs beyond the text binding (DroppyKit 0.2.2).
- DroppyMarkdownHighlightColor — Marker-pen palette for ==highlight== spans (DroppyKit 0.2.2).
- DroppyMarkdownToolbarAction — A formatting command a droplet's toolbar can send to the host editor (DroppyKit 0.2.2).
- settingsSectionHeader(_:) — Single source of truth for the section headers that sit above settings cards.
- settingsUnifiedSegmentLabel(icon:title:isSelected:isEnabled:) — One option of a settings pop-up: its SF Symbol, then its title, in the menu's own font.
- settingsUnifiedPickerRow(title:subtitle:icon:iconColor:options:accessibilityLabel:groupPosition:isEnabled:isSelected:action:content:) — The canonical picker row: a title on the left and the native pop-up on the right, one line, inside a DropletSettingsCard.
Versioning
The handshake between a droplet and its host.
- DroppyKitBuild — Version constants of this DroppyKit build.
- SemanticVersion — A three-component semantic version, compared numerically.
- DropletCompatibilityVerdict — Outcome of evaluating one bundle against one host.
- DropletHostDescriptor — What one host advertises to the handshake.