The iCal format, first defined as a standard as RFC 2445 in 1998, is the universally accepted format for distributing calendar files, mainly used for distributing events.

As part of my QR code scanning app Scanula I added support for detecting events in scanned objects. Thanks to the fantastic libical and the Swift wrapper swift-ical it's fairly easy to parse an iCal feed, but adding it to iOS is a bit trickier.

The best solution I have come up with is to host a small HTTP server within the app that redirects all requests to a data: URL containing the iCal text. Hosting a server to serve a single file to be able to add calendar events may sound a bit farfetched. To explain why I think this is the best solution I'll first go over the alternatives, with the final solution at the end.

In versions prior to 1.2 of Scanula I would create a new EKEventEditViewController, passing in a few pieces of information from the scanned iCal text. This is done by creating an EKEvent object and populating the various properties. This has a few downside though:

  • It's easy to miss a field
  • Not all fields supported by iCal are supported by EKEvent
  • There's a lot of manual code required to setup the properties

So I started looking in to alternatives. The first thing that I tried was to write the iCal text to a temporary file and then providing the URL to a UIDocumentInteractionController. I thought this would offer the user the option to add the event to their calendar, however, the bottom bar is simply missing. This isn't a commonly reported issue, but there others commenting on this (1, 2). Pre-requesting access to the calendar does not fix this; I assume that Apple's Safari and Mail can do this thanks to a special entitlement.

I thought that my best bet was to handoff the handling to Safari, but since the iCal text has been scanned via an image I can't provide a web URL for Safari to open. Since Shortcuts has special permissions it can open Safari with a non-http(s) URL, e.g. a data: URL. So I created a Shortcut that opens Safari with a hardcoded data:text/calendar;base64,{base64-encoded-ical} URL. It worked! But how can I have this be done by my own app?

SFSafariViewController has the same limitation and will crash when opening a non-http(s) URL. This is where the local web server comes in. By hosting a web server on port 8080 within the app and passing in localhost:8080 as the URL it will query the local web server. All this web server does it redirect to data:text/calendar;base64,{base64-encoded-ical} and then stop itself. For this I used Embassy because it seemed the most lightweight.

The UX of this isn't quite perfect because it will present an SFSafariViewController, which will then present the view controller containing the calendar event. When the calendar event view controller is dismissed the user is left with an empty SFSafariViewController, but I feel that these are all fair tradeoffs.

I do this with a fairly simple server:

import Embassy
import Foundation

public final class RedirectionServer {
    public private(set) static var shared = RedirectionServer()

    private var loop: EventLoop?
    private var server: HTTPServer?
    private var loopQueue: DispatchQueue?

    public func redirectNextRequestTo(_ url: URL) throws -> URL {
        tearDown()

        let loop = try SelectorEventLoop(selector: try KqueueSelector())
        let server = DefaultHTTPServer(eventLoop: loop, port: 8080) { [weak self] _, startResponse, sendBody in
            startResponse("302", [
                ("Location", url.absoluteString),
            ])
            // Empty data ends response
            sendBody(Data())

            self?.tearDown()
        }

        try server.start()

        let loopQueue = DispatchQueue(label: "Redirection Server")

        self.loop = loop
        self.server = server
        self.loopQueue = loopQueue

        loopQueue.async {
            loop.runForever()
        }

        return URL(string: "http://localhost:8080")!
    }

    private func tearDown() {
        server?.stop()
        loop?.stop()

        server = nil
        loop = nil
        loopQueue = nil
    }
}

Presenting the UI to the user is also fairly simple:

let data = Data(iCalString.utf8)
let base64Calendar = data.base64EncodedString()
let calendarDataURL = URL(string: "data:text/calendar;base64," + base64Calendar)!

DispatchQueue.global().async {
    do {
        let redirectionURL = try RedirectionServer.shared.redirectNextRequestTo(calendarDataURL)

        DispatchQueue.main.async {
            let safariViewController = SFSafariViewController(url: redirectionURL)
            safariViewController.modalPresentationStyle = .formSheet
            parentViewController.present(safariViewController, animated: true, completion: nil)
        }
    } catch {
        print("Failed to start redirection server:", error)
    }
}