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 AXUIElement call is a round trip to another app, and it waits as long as that app is busy. Set a short AXUIElementSetMessagingTimeout and make the calls off the main actor.
  • Processes. Launching a tool and waiting for it, see below.
  • Anything synchronous over the network, and any DispatchSemaphore or wait() 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, summary and description in droplet.json included.
  • Other languages follow Droppy's. Your code runs in Droppy's process, so Bundle.main is Droppy: pick your strings for Bundle.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.

See also