Skip to content
C.W.K.
Stream
Lesson 06 of 06 · published

URL Schemes: A Door Any Page Can Knock On

~16 min · macos-citizen, url-schemes, deep-links, launchservices, security

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A web page can never turn the scheme into an arbitrary command runner, because nothing in the URL is a command."

Declaring and Receiving a Scheme

An app claims a scheme with CFBundleURLTypes in its Info.plist, and the claim becomes real when LaunchServices registers the bundle, which is one more reason the build script ends with lsregister -f. After that, open spark://…, a link in a browser, or NSWorkspace.shared.open(url) from another app all reach it. An AppKit app receives the URL in application(_:open:), a SwiftUI scene in .onOpenURL. When the URL is what launched the app, it can arrive before the interface exists, which is why the AppKit track parked early requests and drained them once the window and any restore were ready. When no app claims the scheme, open fails with kLSApplicationNotFoundErr (-10814), and a browser may show nothing at all.

The Door Has No Lock

The family's workshops (the quest kiln, the video workshop, the market engine and others) each have a web page with a Launch button that starts a coding session on the Mac in front of you. The button opens <scheme>://launch?ticket=…, and a small signed launcher app handles it. The design starts from one fact: any page the browser loads can open that same URL. So the launcher treats the URL as an untrusted knock. It accepts one scheme, one host (launch) and one parameter, an opaque one-use ticket. It consumes the ticket against the engine, and everything dangerous is derived locally: the engine address and folders come from that Mac's config file, the only prompt it will ever send comes from a template in its own bundle plus the consumed work-item id, and a repository directory named by the engine must be a single existing component under a configured root. The video player's rule is the same idea from the other side: never put a media path in its URL scheme, because accepting a path would reopen a boundary the video-memory engine had already closed.

The Sender's Race

The launcher also sends deep links, to the desktop apps that host the coding sessions, and that direction has its own trap. When the target app was not running, macOS launched it with the URL, and the app processed the link during its own initialization: the folder parameter half-applied, the workspace never attached, and a trust prompt reappeared on every dispatch. A warm app attached cleanly, which made the bug look intermittent. The fix is two steps: open the application alone, wait until it has finished launching, give its URL handler a settle delay, and only then open the deep link. Running is not the same as ready.

Make a Missing Handler Visible

One workshop's Launch button quietly did nothing: the engine issued tickets and no launcher was installed on that Mac to consume them. A URL scheme cannot report that nobody answered. The ticket can. The web page polls the ticket's status, so "issued, never consumed" shows up as a state instead of silence.

Code

Info.plist fragment: claim the scheme, stay out of the Dock·xml
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.example.spark.launch</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>spark</string>
    </array>
  </dict>
</array>
<key>LSUIElement</key>
<true/>
Receiver: accept only a ticket. Sender: warm a cold app before the link flies·swift
import AppKit

enum LaunchRequestError: Error, Equatable {
    case wrongScheme, wrongHost, missingTicket, unexpectedParameters
}

/// The only thing a web page may hand this app is an opaque ticket. Nothing in the URL is a command.
func launchTicket(from url: URL, scheme: String = "spark") throws(LaunchRequestError) -> String {
    guard url.scheme == scheme else { throw .wrongScheme }
    guard url.host() == "launch" else { throw .wrongHost }
    let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
    guard let ticket = items.first(where: { $0.name == "ticket" })?.value, !ticket.isEmpty else {
        throw .missingTicket
    }
    guard items.count == 1 else { throw .unexpectedParameters }
    return ticket
}

/// Sender side: a cold app may handle a URL during launch, before its handler is ready.
@MainActor
func openDeepLink(_ link: URL, appAt appURL: URL, bundleID: String) async throws {
    if NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).isEmpty {
        let configuration = NSWorkspace.OpenConfiguration()
        configuration.activates = false
        try await NSWorkspace.shared.openApplication(at: appURL, configuration: configuration)
        for _ in 0..<60 {   // up to 15 s for launch to finish
            let running = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
            if running.contains(where: \.isFinishedLaunching) { break }
            try await Task.sleep(for: .milliseconds(250))
        }
        try await Task.sleep(for: .milliseconds(2500))   // running is not the same as ready
    }
    NSWorkspace.shared.open(link)
}

External links

Exercise

Give Spark's Mac app a spark scheme and route application(_:open:) through launchTicket(from:), logging either the ticket or the error. Install and register the app, then run open 'spark://launch?ticket=abc', open 'spark://launch?ticket=abc&folder=/tmp' and open 'spark://run', and confirm each log line. Add a self-test for launchTicket(from:) covering the empty ticket and the extra parameter. Finally, quit Spark and open the first URL cold: does your handler still log it?
Hint
If open reports -10814, LaunchServices does not know the scheme yet: run /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f on the installed bundle (it is not on PATH). For the cold case, the URL can arrive before applicationDidFinishLaunching has built anything, so park it and handle it once the app is ready.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.