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.