Ben Dodson

Freelance iOS, macOS, Apple Watch, and Apple TV Developer

Browser Note and the process of building an iOS 15 Safari Extension

I order a Chinese takeaway most weeks and my regular order is half a crispy aromatic duck. This is a bit excessive and so I’ve been aiming to reduce this to a quarter. The problem is that it’s too easy to hit reorder at the end of a long day and whilst I could add a reminder in Things or an alert in my calendar these are very easy to ignore. My solution was to use the new support for browser extensions in iOS 15 to throw up a fullscreen prompt when I go to the takeaway website:

Problem solved.

After using this solution for a week or two, I quickly found myself wanting to use it in on other sites such as BBC News and Twitter to prevent me from habitually visiting them. I developed the extension further so I could add notes directly within Safari and instantly saw my phone usage drop by several hours a week. It turns out that adding a short “are you sure” dialogue before visiting a website is a great way to break the habit1.

Whilst I make a lot of apps for my own personal use, this seemed like something that could be beneficial for everybody, particularly as I could not find any apps that already do this. Therefore, I’m very happy to release my first self-published app in over 5 years: Browser Note.

Browser Note is an app that does one thing incredibly well; it adds fullscreen notes to webpages. It does this by making use of an iOS 15 Safari Extension that you can activate within Safari to add your note and later update or delete it. When a note is displayed on a page, a “View Website” button is available to let you continue into the site; when tapped, the note is snoozed for a period of time at which point it will appear again on any subsequent reloads (the snooze period is 15 minutes but you can change this within the app).

All of your notes are visible within the app (shown left). You can add, update, and delete your notes by tapping the Browser Note icon within Safari (shown right).

There is no App Store just for Safari Extensions so instead they have to come bundled within an app. From a development perspective, Safari Extensions are very similar to those made for Chrome and Firefox; Apple even provide a converter to migrate any extensions you’ve built for other browsers. Xcode has a template for Safari Extension based-apps which I used to get started but after that you’re on your own; whilst there is documentation from Apple, it’s predominantly for Safari Extensions on macOS which are very different. The most frustrating aspect is that the documentation does not say which platforms it supports and as many of the APIs are the same it was easy to spend a couple of hours wondering why something doesn’t work only to find it doesn’t apply to iOS2.

My initial thought was to use the Web Storage API to store notes as then everything would be wrapped up in a tidy extension. However, there isn’t a way for the app to access that data so if I wanted to add syncing in future (i.e. between iPad and iPhone) then this was not going to be a viable solution. Most apps that have extensions just give you a tutorial on how to enable them but I wanted to go a bit further and add some functionality within there as well, so again I wanted the app to be able to access the notes that were created.

In the end, I came up with a model whereby the app is in control of all of the notes data within a local Realm database situated in an App Group so both the app and extension can communicate with it. When you load a website, the extension wakes up the app which checks whether there is a note matching the current URL; if there is, it responds with the data and the extension removes all of the HTML from the page and replaces it with it’s own note UI. Similarly, when you invoke the popup sheet to add a note or edit one, the commands are sent through to the app to perform those actions on the database. Within the iOS simulator this is quite slow – it would take a few seconds after visiting a page before the note would appear – but on a device it’s practically instantaneous.

In order to communicate between the extension and the app, there are a number of hoops to jump through. First of all we have the content.js file which is loaded on each page request:

browser.runtime.sendMessage(JSON.stringify({"request": "check", "url": window.location.href})).then((response) => {
    switch(response.type) {
        case "message":
            if (!response.isSnoozed) {
                createContentView(response.id, response.emoji, response.displayText);
            }
            break;
        case "ignore":
            break;
    }
});

This sends a message to the background.js file with the current url and also the request type (there is check, add, update, delete, and snooze - in this case we’re just checking to see if there is a note). If a response comes back with a type of message then we’ll make sure the note isn’t currently snoozed and then throw up the note UI. But how does the response get generated?

browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
    browser.runtime.sendNativeMessage("application.id", request, function(response) {
        sendResponse(response);
    });
    return true;
});

This is the background.js file in its entirety. Not much there 🤣. In order to communicate with the native app, we have to use a sendNativeMessage API but this cannot be used from content.js, it has to be invoked by background.js, hence this background file just acts as a middleman taking any messages from the content and sending them to the native app before shuttling back the response. One important item to note here is that you must return true when you are responding to a message from the content otherwise the connection is closed instantly (i.e. it will not wait for the native app to respond).

Now that we’ve sent a message to the app, it’s time to look at the Swift code that is responding to these events. This is all handled by a SafariWebExtensionHandler:

import SafariServices

class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
    
    func beginRequest(with context: NSExtensionContext) {
        guard let item = context.inputItems.first as? NSExtensionItem, let userInfo = item.userInfo else { return }
        guard let arg = userInfo[SFExtensionMessageKey] as? CVarArg else { return }
        let message = String(format: "%@", arg)
        guard let data = message.data(using: .utf8), let request = try? JSONDecoder().decode(ExtensionRequest.self, from: data) else { return }
        
        guard let requestType = request.requestType else { return }
        
        DispatchQueue.main.async {
            var response: Any = ["type": "ignore"]
            switch requestType {
            case .check:
                guard let url = request.url else { return }
                let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
                if let reminder = DataStore.shared.fetch(with: url) {
                    response = ["type": "message", "id": reminder.id, "displayText": reminder.displayText.replacingOccurrences(of: "\n", with: "<br>"), "emoji": reminder.emoji, "text": reminder.text, "isSnoozed": reminder.isSnoozed, "device": device]
                } else {
                    response = ["type": "ignore", "host": url.host ?? "", "device": device]
                }
            case .add:
                guard let url = request.url, let text = request.text else { return }
                let blockHost = request.blockHost ?? true
                let reminder = Reminder(url: url, text: text, matchExactPath: !blockHost)
                DataStore.shared.add(reminder)
                response = ["type": "reload"]
            case .update:
                guard let id = request.id, let text = request.text else { return }
                DataStore.shared.updateText(id: id, text: text)
                response = ["type": "reload"]
            case .delete:
                guard let id = request.id else { return }
                DataStore.shared.delete(id: id)
                response = ["type": "reload"]
            case .snooze:
                guard let id = request.id, let reminder = DataStore.shared.fetch(with: id) else { return }
                reminder.snooze()
                response = ["type": "reload"]
            }
            let responseItem = NSExtensionItem()
            responseItem.userInfo = [SFExtensionMessageKey: response]
            context.completeRequest(returningItems: [responseItem], completionHandler: nil)
        }
    }

}

The data comes through as a string within the user info dictionary of an NSExtensionItem. I send JSON from the extension via this mechanism as then I can use the Codable protocol in Swift to unpack it into a native struct. I then look at the request type and perform what actions are needed on the database before responding with a dictionary of my own; if there is a note then that data is sent back, otherwise the response will tell the extension to either do nothing or reload the page. In the end, we have an extension that can talk with the native app and store all the data in the Swift world allowing me to display the notes and allow them to be deleted from within the app:

Notes are stored within the app so can be deleted natively. I could also add the option to add and edit notes within the native app but I cut that functionality from the initial release so I could get it shipped.

As I mentioned, I would like to add note syncing in a future version that would allow the iPhone and iPad apps to remain in sync. I’m also intrigued as to whether a Catalyst version of the app would get this working on macOS but after over 30 hours of development I decided it was best to get an initial version launched before adding additional features.

Conclusion

This project started as a simple alert for a single webpage but has quickly become a labor of love for me as the benefits of a pause in web browsing became apparent. I’ve spent far too much time tweaking aspects of the app and haven’t bought up a number of tiny details I’m very proud of:

  • I used SwiftUI via a UIHostingController to create the tutorial panels shown on app launch. These were able to be reused really easily within the settings page.
  • There are videos to show how to install the extension and use the app. I used data within an Asset Catalogue for the first time so I could have a different video depending on whether the app is installed on iPhone or iPad (and due to App Thinning it means the app doesn’t even include the other device video thus reducing download and install size).
  • I’m really pleased with the app icon I designed and am slightly surprised it got through App Review; it’s the Safari logo in green but using a pen for the compass arrow.
  • Speaking of App Review, I’m amazed this screenshot was allowed 🤣
  • The app resizes beautifully on iPad and all of it’s various multitasking window sizes. This also goes for the HTML within the extension which is in a modal popup on compact sizes but a floating panel on regular sizes.
  • Dark mode is fully supported so your note will reflect the system theme.

I’d love it if you would give Browser Note a try. It’s available on all devices running iOS 15 and above and is available worldwide3 now.

  1. I have attempted to do this in the past using Screen Time but it’s a blunt instrument. It can only apply to an entire domain and you can’t personalise the text that appears so it’s only really useful as a block; you don’t get the positive reinforcement from a message you’ve written. ↩︎

  2. I’m looking at you SFSafariExtensionManager; there is no way for your app to know if the extension is installed correctly on iOS. ↩︎

  3. Well, almost worldwide. 🇷🇺🇧🇾 ↩︎

Web Inspector on iOS devices and Simulators » « Side Project: Stoutness