Thinking outside of the WKWebView

It is almost impossible to use a native Appkit web view inside a macCatalyst application. In this article, we show you how the Craft team overcame this obstacle so that users can have a better whiteboard-editing experience.

Published:
b59393f6-4911-4233-91bd-1f5cd71d8fef.png

Craft on iOS and Mac is a powerful native application. Our app team consists of senior experts who can quickly and efficiently create high-quality native experiences. 

There are cases though, where the native experience is not the currently available best solution for a feature. There are cases, where the feature requires a technology that is so complex, that investing in a native implementation is huge and its early return on investment is not clear. 

In these rare cases, we try to validate the future of the feature by using existing web technologies in a WKWebView. Later on, if users love the feature, we can turn this into native experiences. 

Our whiteboard feature was such a case, where we decided to use a polished web library called tldraw to provide canvas editing earlier inside the Craft iOS and Mac applications.

image.pngimage.png


The WKWebView stuck between platforms

We use the macCatalyst framework to present the Craft editing experience on Mac. See our general learnings about this framework here

During the implementation of the whiteboard (and previous web features) we encountered strange issues with the built-in macCatalyst WKWebView: 

  • input/focus handling inside the webview was a two-step process: the webview had to be first responder, and then the input field had to be clicked. This required a user to click two times on any input field to start editing. We solved it by adding a hover gesture to the WKWebView that brings the HTML element on the web page into focus when it hovers, so the first click on the input element is enough to start typing. 
  • text selection changes in text areas or editable divs were not reported correctly: When you select a text, the web view will create native views for selection highlights and put it over the web view's content. In our cases, these highlight views were not always in sync on what is actually selected on the web page. We disabled text selection overlays on the native side and implemented this on the website correctly.
  • scrolling with touchpad: we also noticed that when scrolling on the canvas with the touchpad, after ending and releasing the drag gesture, the canvas instantly stopped and there was no decelerating, which we are used to on native scroll views.
  • convenient shortcuts: basic shortcuts like holding the command button and scrolling with the mouse, did not zoom the web view at all
  • Apple also removed sending two finger taps as right clicks to the WebKit framework from the native side: so right-click context menus (e.g. export) stopped working. This happened in a minor OS release between macOS 14.0 and 14.2. We learned this from a mailing list where a WebKit developer noticed and wrote about the issue. 

We did not see such issues with the iOS and the Appkit WKWebView, so naturally, we tried to move our current solution on Mac away from the macCatalyst implementation and towards the Appkit WKWebView.


Runtime madness

With macCatalyst, you have a trick where you can access the Appkit wrapper framework components through a Mac-only plugin bundle loaded at runtime. 

Our first attempt to create an Appkit WKWebView was to use this trick and create it inside this bundle. After many trials and errors, we abandoned this idea due to the limitation/security of the Objective-C runtime. 

When you init an Objective-C class (like WKWebView) from code, the runtime will look up the metadata and allocation/init methods depending on the name of the class. It happens to be that the name for the iOS WKWebView class is the same as for the macOS one

#if TARGET_OS_IPHONE WK_EXTERN API_AVAILABLE(macos(10.10), ios(8.0))
@interface WKWebView : UIView 
#else WK_EXTERN API_AVAILABLE(macos(10.10), ios(8.0))
@interface WKWebView : NSView
#endif

The iOS WKWebView class is loaded by the system before our code is running, so its metadata will occupy the spot for the "WKWebView" name. Later developers can’t unload it manually due to security reasons. 

When we wrote WKWebView(frame:, configuration:) in our AppKit bundle, an iOS web view was created and crashed when we tried to add it to the NSView hierarchy. 

This is also a reason why you should be careful using the Mac framework in a macCatalyst application this way. They might have a web view encoded into an xib/nib and that will crash on loading (the persisted Mac properties won't be found on an iOS web view).

This path seems to be a dead end. Okay then…

If the runtime won't come to Craft, then Craft must come to the runtime


Enter the companion app

We decided to gain control over, what type of web view class is registered and when in runtime by creating a separate, pure Appkit, companion app. This companion app is very simple, it only manages Appkit WKWebViews. 

When you click on a whiteboard in the Mac app: 

  1. The main app creates a proxy object for this whiteboard window in the main app, this will represent the actual Appkit WKWebView window from the companion app.
  2. The Craft app spawns a companion app (CraftWhiteboard) and ensures it finishes running. After this, both app builds up an inter-process communication bridge towards each other. They are within the same App Group, so others can't connect to them. 
    • The main app communicates web URL load requests, system preferences changes (like preferred light/dark mode), and termination requests. 
    • The companion app communicates WKWebView message handlers and its process states. 
  3. The Craft app sends the whiteboard data to show with an ID to identify this whiteboard session in the proxy and in the web view window.
  4. The companion registers the request and creates the window for the whiteboard.
  5. The user edits the whiteboard and if there is communication needed, then the proxy can send a message to the represented Appkit WebView window with a JSON message.
  6. When the user closes the window, the companion app reports back that it will close, so both the window and the proxy are deleted.
  7. When the user closes the Craft app from the dock or with CMD + Q, the main app will send a terminate command to the companion app. 

Opening a separate app, when you tap on a whiteboard, that starts bouncing on the dock with another Craft logo, would be a very strange and confusing experience. 

Fortunately, there is a form of app called app agents, that can exist without icons on the doc and app switcher. You open the app, but the user sees only a window.

<key>LSUIElement</key>
<true/>
Relevant part of the Info.plist from the companion app

Originally this option was for daemons and top menu applications. These apps can also manage windows, so they can be used to extend the experience of a normal application.

Fun fact: The Quicklook framework also uses this technique, where the file opened for preview is loaded in a separate process (for security reasons) and the preview is projected into a host view into the main app.


Over-communication is key

When two applications collaborate, keeping them in sync is important. Also, we keep the companion app running along with the main app and we do not quit it after you close the last whiteboard window. This helps to launch the future whiteboards faster. When the app is quit, then we terminate the companion app too.

There can be a case (like a crash) when the companion app outlives the main app, but on the next whiteboard opening, we should be able to reconnect. 

To keep the two apps in sync:

  • the companion app sends a process start/end message to the main app
  • in the start message, it also sends its process ID, so the main app can check if the app needs to be launched or not or reconnect if needed
  • the main app sends “close windows” or “terminate” commands to the companion app to close alongside the main app (also needed to comply with App Store guidelines)


Appkit baby steps

Luckily, the above-detailed approach solved all of the WKWebView issues we had. This was also the first time we dealt with a "pure" Appkit application, so we learned a lot during the process too:

  • at least one NSWindow: We modified a normal Mac application to become the agent app, we needed. This combination required at least one window to be alive in the app, when we closed the last window, the companion app crashed. We solved this by always having a strong reference on the last closed window.
  • it is useful to keep the initial Storyboard: We tend to build everything from code, and the companion app had a really simple view hierarchy (full-screen web view), so we thought it would save space, if we deleted the initial Main.storyboard and created the UIApplication and root view controller by hand. It turned out, that the default app setup with the Storyboard is responsible for building the basic key commands menu for the application (even when it is not visible in the top menu bar). With this move, we lost default CMD+C/V/X support inside the web views. So in the end we put this file back.
  • The “Space” key is reserved to jump by screen height in the WKWebView content: We've got internal feedback before the release, that they hear strange error sounds when grabbing and moving the whiteboard with space + mouse combination. It turned out, that the Space key, by default, is reserved to jump in WkWebView's content with the height of the view. As the canvas was already full height, there was no next page to scroll to, so the system played an error sound. We had to disable handling this from the JavaScript side with an injected script to prevent the default behavior.
  • dragging the window: We disabled the default title area for the whiteboard windows and made the web view full-screen. This caused the web view to steal the window drag events. We overcome this by overriding the web view's hitTest method and making it ignore the top dragging area. 
  • WKWebview open file dialog: For an Appkit WKWebView, you need a Mac-only delegate method implementation to be able to present a file dialog for the user, when the web page would like to import a file. In our case, this happens when the user wants to add an image or video to the whiteboard. On iOS/macCatalyst, this happens out of the box. To comply with this, we had to implement this method:
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) { //... }


See you on the next whiteboard!

Screenshot 2024-07-25 at 14.09.00.pngWith this approach we could provide a full native, Appkit webview experience for our users. Besides fixing the WKWebview issues, I mentioned in the beginning, this solution:

  • gives Whiteboard editing a separate space and allows the user to focus on the whiteboard editing
  • allows users to have multiple whiteboards open without opening the document in a new tab
  • separates the Craft app's performance and the Whiteboard app's performance, so they won't affect each other's run. They also have their own allocated memory.

That's all the Craft hackery we wanted to share, for now, please give Whiteboards a try. If you have questions you can reach me at peter@craft.do!

Interested? Read More...