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

SurfaceProtocolDroppy 15.3
PreferencesDropletPreferencesServiceMounted
EnvironmentDropletEnvironmentServiceMounted
LoggingDropletLogServiceMounted
Install stateDropletInstallStateServiceMounted
WorkspaceDropletWorkspaceServiceMounted
FeedbackDropletFeedbackServiceMounted
PermissionsDropletPermissionsServiceMounted
Global shortcutsDropletShortcutsServiceMounted
Mini HUDDropletHUDServiceMounted
Shelf open and closeDropletShelfServiceMounted
Live activity seatDropletLiveActivityServiceMounted
Live activitiesLiveActivityProvidingMounted
Lock screen rowsLockScreenStatusProvidingMounted
Menu bar extrasMenuBarExtraProvidingMounted
Shelf widgetsShelfWidgetProvidingMounted
Expanded surfacesExpandedSurfaceProvidingMounted
Settings panesSettingsPaneProvidingMounted
Settings pagesSettingsPagesProvidingMounted (DroppyKit 1.13.0)
Settings page toolbarmakeSettingsPageToolbar(id:context:)Mounted (DroppyKit 1.18.0)
URLs (droppy://droplet/<id>/…)DropletURLHandlingMounted (DroppyKit 1.11.0)
Launcher bridgeDropletLauncherServiceMounted (DroppyKit 1.12.0)
Minimum macOS (minMacOS)minMacOSEnforced (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>.droplet

at 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 shelfIsland
Top shoulders9, outward3232n/a
Bottom corners12284018 closed, 44 open
Width on a notchcutout + 70 a sidecutout + 120 a sidecontent + insets150 / 240
Content inset, topn/a374716
Content inset, sides22484812 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:

NotchIsland
Solo shellwidget width + 3 + 96, never under 451widget width + 3 + 32, never under 405
Smallest solo widget352370
Standard height9393
Fixed height48 through 480, then + 3 guttersame
Backgroundnone: content sits on the shell, like Droppy's own widgetssame
Clip corner18 alone; square in a row or wrappedsquare in a row; alone, none (the island shell clips it)
Content inset (contentInsets)8 alone; 0 in a row or wrapped0
Grouped card heightdeclared height − 16declared height − 16
Notch wrap (notchWrap)alone, opted in, a real notch, bands wide enoughnever

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 widgetDescriptors when the droplet starts, stops, is updated or is removed, after every state change, and on invalidateLayout(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.