What if I just copy-paste from the web?

This article is part of a series.

Intro

As noted before Apple makes it very easy to create a Safari Web Extension if one already has an extension that works in another browser. Just run the command line tool. Done. If starting from nothing there’s even a starter app in the New > Project dialog.

If instead the idea is to augment an existing app that maybe doesn’t already have a online-service connected to it, how to handle getting the information from one sandbox to the other?

One easy way would be to facilitate copying and pasting the information from the web to the original app.

That can be done in just a handful of steps. Assuming knowledge of Swift and basic web development already, which maybe assuming a lot, but here we go.

Have a starting app

Meet Lines.

This App works by loading a text file of lines into a view on start up. Each of the lines is a link that will take you to a URL where the citation comes from.

The format of a Line is very simplistic at rest in it’s text file:

{string_part}|{url_part}

Screenshot of Lines. In Dark mode. It’s very plain, but it has an Aliens by Amy Lowell as the lines in it

Add the demo extension

Meet LineGrabber

Because I want a macOS native app I split the original app into 2 targets, one for the iOS/iPadOs family and one for macOS. In addition, I added a single Safari App Extension Target, which got me all the demo files. I then copied that target as well. I made sure each target had its own entitlements file and the two extensions had their own info.plist files to be extra careful. Every target then needed the new file location information put into their build settings. (search for “entitlements” and “info.plist file”).

The iOS/macOS split supports the differences in how AppGroups are handled. iOS & the Tablettes require a developer account to have an AppGroup. Mac Apps do not.

Screenshot of Xcode showing the reorganized files

Right now the extension just has the default extension code, but it does get up and running on both platforms using SwiftUI in the shell app instead of the UIKit/WebKit boilerplate that comes in the full sample app.

I wrote an ExtensionManager:

import Foundation
import SafariServices.SFSafariApplication
#if os(macOS)
import SafariServices.SFSafariExtensionManager
#endif
import os.log


class ExtensionManager:ObservableObject {

#if os(macOS)
    var isEnabled = false
    func setExtensionStatus() async {
        do {
            isEnabled = try await SFSafariExtensionManager.stateOfSafariExtension(withIdentifier: Constants.macBundleID).isEnabled
        } catch {
            await NSApp.presentError(error)
        }
    }
    
    func openSafariSettings() async {
        do {
            try await SFSafariApplication.showPreferencesForExtension(withIdentifier: Constants.macBundleID)
            //kills the app.
            //If don't kill the app, rewrite so view with status is flagged as stale somehow.
            await NSApplication.shared.terminate(nil)
        } catch {
            await NSApp.presentError(error)
        }
    }
    
    func sendBackgroundMessageToExtension(title:some StringProtocol, message:Dictionary<String,String>) async {
        do {
            try await dispatchMessage(title: title, message: message)
            os_log(.default, "Dispatching message to the extension finished")
        } catch {
            os_log(.default, "\(error)")
        }
    }
    
    func sendBackgroundMessageToExtension(title:some StringProtocol, message:Dictionary<String,String>) {
        SFSafariApplication.dispatchMessage(withName: title as! String, toExtensionWithIdentifier: Constants.macBundleID, userInfo: message) { (error) -> Void in
            os_log(.default, "Dispatching message to the extension finished \(error)")
        }
    }
    
    //MARK: async wrappers on SFSafariApplication
    
    func dispatchMessage(title:some StringProtocol, message:Dictionary<String,String>) async throws {
        let result = await withCheckedContinuation { continuation in
            SFSafariApplication.dispatchMessage(withName: title as! String, toExtensionWithIdentifier: Constants.macBundleID, userInfo: message) { messages in
                continuation.resume(returning: messages)
            }
        }
        if result != nil { throw result! }
        
    }
#endif
}

That drives the part of the UI related to the extension:

import SwiftUI

struct ExtensionInfoView: View {
    let extensionInfo = ExtensionManager()
    var body: some View {
#if os(macOS)
        macExtInfoView().environmentObject(extensionInfo)
#else
        iOSExtInfoView().environmentObject(extensionInfo)
#endif
    }
}

The iOS view…

import SwiftUI

struct iOSExtInfoView: View {
    @EnvironmentObject var viewModel:ExtensionManager
    
    var body: some View {
        VStack {
            HStack {
                
            }
            Text("Turn on the Safari extension \(Constants.extensionName) in “Settings › Safari” to grab new lines.")
                .multilineTextAlignment(.center)
                .padding(.horizontal)
            Button("Open Settings") {
                // Get the settings URL and open it
                if let url = URL(string: UIApplication.openSettingsURLString) {
                    UIApplication.shared.open(url)
                }
            }
            }
            if let url = URL(string: Constants.goodSamplePage) {
                Link("Open Example page", destination: url)
            }
            
        }
    
}

… and the macOS view

#if os(macOS)
import SwiftUI

struct macExtInfoView: View {
    @Environment(\.openURL) var openURL
    
    @EnvironmentObject var viewModel:ExtensionManager
    @State var enabledStatusText:String = ""
    var body: some View {
        VStack {
           
            Text("Extension is: \(enabledStatusText)")
                .font(.title2)
                .lineLimit(nil)
            
            Button("Quit and Open Safari Settings") {
                Task {
                    await viewModel.openSafariSettings()
                }
            }
            Button("Open Example Page / openURL style") {
                openURL(URL(string: Constants.goodSamplePage)!)
            }
            Button("Send Message To Extension") {
                 viewModel.sendBackgroundMessageToExtension(title: "DemoMessage", message: ["Hello":"World"])
            }
            
        }.task {
            await viewModel.setExtensionStatus()
            enabledStatusText = viewModel.isEnabled ? "enabled" : "disabled"
        }
    }


}

…have different capabilities. Notice how much more the macOS version can do at this stage. That’s because of the legacy of SafariServices.SFSafariExtensionManager. The Constants.macBundleID used in related functions is the bundle id of the extension target. This id allows Safari to provide the needed NSExtensionContext for the requests. Apple seems to want people to focus on building as much as possible using the JavaScript APIs to make the browser plugins as cross-platform portable as possible and I think that’s great. Double thumbs up. That’s what we’ll do.

Screenshot of the macOS version of the Lines App, now with extension related details like whether the extension is enabled.

Update the JavaScript

At this point we only need to change two files.

Manifest File

manifest.json goes from

    "content_scripts": [{
        "js": [ "content.js" ],
23:      "matches": [ "*://example.com/*" ]
    }],

38:   "permissions": [ ]

to:

    "content_scripts": [{
        "js": [ "content.js" ],
23:      "matches": [ "<all_urls>" ]
    }],

38:   "permissions":  [ "activeTab", "scripting",  "clipboardWrite"]

Those changes mean that, once given permission, the extension will be able to inject scripts into any page and write its content to the clipboard as long as it’s the active tab.

Content Script

At this point it will do all of the script injecting from content.js

console.log("hello from content.js");

//need other approach on FireFox..
//https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard
function updateClipboard(newClip) {
    navigator.clipboard.writeText(newClip).then(
        () => {
            console.log("clipboard write success:", newClip)
        },
        () => {
            console.log("clipboard write failed:", newClip)
        },
    );
}


var copyable = Array.from(document.getElementsByTagName('p'))
.concat(Array.from(document.getElementsByTagName('li')));

for( var i = 0; i < copyable.length; ++i ) {
    copyable[i].onclick = function() {
        //This strips out all tags. If want something more attributed
        //strings friendly use .innerHTML or .innerText
        let text = this.textContent; //.innerHTML;
        let url = document.location.href;
        updateClipboard(text + "|" + url);
    }
}

As a reminder, the strings get stored in lines.txt as a string followed by a pipe character (|), followed by a URL.

This code gets every <p> and <li> from the DOM and gives them all click listeners. When clicked, that text and the URL of the current open tab get bundled into the pasteboard in that same | delimited format the Lines App code uses.

navigator.clipboard.writeText(newClip) is asynchronous and this code from the Mozilla foundation uses the Promise.prototype.then() syntax to provide the success and failure handlers. Promises have been widely adopted for asynchronous web extension API’s for Manifest 3, Safari and Firefox even earlier.

Load a tab, open the console (CMD-OPT-C) and click away to see the log messages.

SwiftUI PasteButton

The point is to bring this copied text back into the Lines App proper, though. That’s easily accomplished in SwiftUI using the [PasteButton][PB] introduced in this WWDC22 video on Transferable

[PB]https://developer.apple.com/documentation/swiftui/pastebutton

    PasteButton(payloadType: String.self) { strings in
        guard let first = strings.first else { return }
        myLines.append(possibleLine: first)
    }
    .buttonBorderShape(.capsule)

This code just takes the first line in the pasteboard and tries to make a line out of it.

This code works, but fails silently which can be hard to troubleshoot. I reworked this a bit later in the process to have append(possibleLine: first) asynchronously return a success flag to let the view model pick some status text to display.

    PasteButton(payloadType: String.self) { strings in
        Task {
            //statusText is an @State var
            statusText = await myLines.updateOrWarn(input: strings.first)
        }
    }.buttonBorderShape(.capsule)
    func updateOrWarn(input:String?) async -> String {
        if input != nil {
            let appendTask = await self.append(possibleLine:input!)
            return appendTask ? "Success" : "Failed to add line text:\(input!)"
        } else {
            return "No message waiting."
        }
    }

Screenshot of iOS simulator version this time, with the paste button

Summary

That was it. That was all it took to be able to snag “Lines” from the web and shove them into our list. There’d be a long way to go to make this actually useful. Starting with seeing if the information can’t go from the extension to the shell app without the clipboard or if the clipboard path can be made less flakey.

This article is part of a series.