Geotagging at SFO Museum, part 10 – Native Applications

This is a blog post by aaron cope that was published on May 18, 2020 . It was tagged sfo, collection, geotagging, oauth2, golang, javascript and macos.

This is the tenth of an 11-part blog post about geotagging photos in the SFO Museum collection. At the end of the last post I wrote:

In the next post we’ll discuss some of our efforts to try and shield museum staff from most, if not all, of the complexity that publishing data has introduced.

In part two of this series I wrote:

In the end we may deploy this application for staff as a hosted website on the internet but we would like to have the ability and the flexibility for staff to also run the application locally, from their desktop, regardless of whether they are using Windows or a Mac or even the Linux operating system. The majority of museum staff are not developers and won’t know how or be able, or want, to install the external dependencies that might be necessary for an application written in another programming language to run.

This was an important motivating factor in our decision to choose to develop our geotagging application using the Go programming language:

Go applications can be pre-compiled in to operating system specific binary applications that don’t require any additional steps or dependencies to run.

And to use Go, principally, as the housing for a traditional web application:

Ultimately, the Go part of our application is little more than a web server wrapped around a standard HTML + JavaScript + CSS web application that could be run in any number of environments. That’s by design so that the “meat” of the application doesn’t get painted in to a corner of any single programming language or platform.

In 2019, during a presentation about SFO Museum’s work on the Mills Field website I said:

We are building for the web first, rather than targeting any of the big platform vendors. The web embodies principles of openness and portability and access that best align with the needs, and frankly the purpose, of the cultural heritage sector.

That still holds true but here’s a quick reminder, from the last post, to illustrate some of the complexity that was introduced in our geotagging application in order to support publishing data to a remote target:

$> bin/server \
	-nextzen-apikey {NEXTZEN_API_KEY} \
	-enable-placeholder
	-placeholder-endpoint {PLACEHOLDER_API_KEY} \
	-enable-oembed \
	-oembed-endpoints 'https://millsfield.sfomuseum.org/oembed/?url={url}' \
	-enable-writer \
	-writer-uri 'whosonfirst://?writer={whosonfirst_writer}&reader={whosonfirst_reader}&update=1&source=sfomuseum' \
	-whosonfirst-writer-uri 'githubapi://sfomuseum-data/sfomuseum-data-collection?access_token={access_token}&prefix=data/' \
	-whosonfirst-reader-uri 'githubapi://sfomuseum-data/sfomuseum-data-collection?access_token={access_token}&prefix=data/' \
	-enable-oauth2 \
	-oauth2-scopes 'user,repo' \
	-oauth2-client-id "constant://?val={OAUTH2_CLIENT_ID}&decoder=string" \
	-oauth2-client-secret "constant://?val={OAUTH2_SECRET}&decoder=string" \
	-oauth2-cookie-uri "constant://?val=debug&decoder=string" \
	-server-uri 'mkcert://localhost:8080'
	
2020/05/05 11:42:37 Checking whether mkcert is installed. If it is not you may be prompted for your password (in order to install certificate files)
2020/05/05 11:42:40 Listening on https://localhost:8080

It is unrealistic to expect most museum staff to ever type that long list of commands and flags to start an application. It is probably unrealistic to expect most museum staff to ever type anything from the command line to start an application. On top of that if you look closely at the example above you’ll see there are sensitive data (the OAuth2 client secret, for example) that shouldn’t be broadly shared with staff. A better scenario would be to develop a native desktop application that bundles our Go-based geotagging application, takes care of launching it and opens a link to the application in a web browser.

Installation view of "The Flight Bag: Icon of Air Travel (2003). Photo by SFO Museum.

For the purposes of this post when I say “native” I am going to be talking about macOS desktop applications and I am going to be talking about developing those applications using Apple’s official application programming interfaces and the Swift programming language. There are other ways to develop native, and more importantly cross-platform, applications using web technologies, notably the Apache Cordova, ElectronJS and React Native projects. In the future we might revisit some of these tools but the decision to focus on native macOS development, right now, was made for three reasons:

In the same way that we can wrap a traditional web application in a Go program, can we wrap that Go program in a native macOS application? Each platform has its own unique affordances and tolerances. A larger goal for the museum is recognizing the possibilities that each platform affords so that we might be able to treat them as a kind of “kit of parts” to be reconfigured as needed for future projects.

Developing a “native application that bundles our Go-based geotagging application, takes care of launching it and opens a link to the application in a web browser” is not actually that complicated, at least not initially. Matt Holt’s Packaging a Go application for macOS and Brad Greenlee’s Write a Mac Menu Bar App in Swift are good examples for possible approaches. Both approaches bundle a copy of the geotagging application server binary and take care of starting it with all the flags listed above.

Here are some problems that are common to either of these approaches:

Here are a few more examples of other problems I ran in to trying to bundle the server binary as a simple “clickable” desktop application, following Matt Holt’s instructions:

> ~/sfomuseum/Geotagger.app/Contents/MacOS/run
2020/05/07 12:58:22 Checking whether mkcert is installed. If it is not you may be prompted for your password (in order to install certificate files
2020/05/07 12:58:22 Failed to create application server, exec: "mkcert": executable file not found in $PATH

The mkcert application, which was discussed in detail in the last post is used to create the necessary files so that the geotagging application can use encrypted network connections while running locally on a user’s desktop. Because mkcert is written in the Go programming, we can compile in to native macOS binary and bundle is alongside the server application but the startup process still fails:

May  7 14:12:55 com.apple.xpc.launchd[1] (org.sfomuseum.geotagger.12020[25469]): Service exited with abnormal code: 1

The mkcert application does not expose its functionality as a library that our geotagging application can invoke in code. Instead our geotagging application needs to launch mkcert as a sub-process which is not something that Apple’s launchd service, the thing that is ultimately launching the geotagging server application, allows.

Even if it did the mkcert application itself has to install the certificate files, used to create the secure connection between the geotagging application and user’s web browser using the operating system’s sudo command which is absolutely not allowed. Or rather it can be allowed but requires a lot of extra scaffolding to create a “privileged helper” which introduces an entirely new layer of complexity to manage.

Maybe I could just bundle the certificate files necessary to set up an encrypted connection? Don’t ever do this. It’s a terrible idea. Any possible benefits of this approach are outweighed by the bad practices it fosters and the potential for bad actors to abuse them. Also, it doesn’t solve the problem of installing the necessary certificate authority files so your browser will know to trust your local certificates. In a world where any old application could easily and silently tell your web browser what to trust things would get messy, complicated and ugly very quickly.

Even if it were possible to launch the geotagging application server binary, complete with all the necessary command line flags, there is the potential for leaking sensitive data by inspecting the list of running processes on the computer where the application is running. For example if I run the ps command filtering for things matching server I see this:

$> ps auxwww | grep server
sfomuseum 25202   0.0  0.1  5023148  10264   ??  S     1:45PM   0:00.03 \
	/usr/local/sfomuseum/Geotagger.app/Contents/MacOS/server \
	-nextzen-apikey {SENSITIVE_DATA} \
	-enable-placeholder \
	-placeholder-endpoint {SENSITIVE_DATA} \
	-enable-oembed \
	-oembed-endpoints https://millsfield.sfomuseum.org/oembed/?url={url} \
	-enable-writer \
	-writer-uri whosonfirst://?writer={whosonfirst_writer}&reader={whosonfirst_reader}&update=1&source=sfomuseum \
	-whosonfirst-writer-uri githubapi://sfomuseum-data/sfomuseum-data-collection?access_token={access_token}&prefix=data/ \
	-whosonfirst-reader-uri githubapi://sfomuseum-data/sfomuseum-data-collection?access_token={access_token}&prefix=data/ \
	-enable-oauth2 \
	-oauth2-scopes repo \
	-oauth2-client-id constant://?val={SENSITIVE_DATA}&decoder=string \
	-oauth2-client-secret constant://?val={SENSITIVE_DATA}&decoder=string \
	-oauth2-cookie-uri constant://?val={SENSITIVE_DATA}&decoder=string \
	-server-uri tls://localhost:8080?cert=/usr/local/sfomuseum/Geotagger.app/Contents/MacOS/localhost-cert.pem&key={SENSITIVE_DATA} 

The measure of how sensitive any given value of {SENSITIVE_DATA} is will vary from environment to environment. The point is that it’s not not-sensitive so it merits erring on the side of caution. Even if I build a version of the geotagging application with all of its sensitive data baked in to the application, removing the need for command line flags, there is still a risk of leaking that data using tools like the strings command.

For example:

$> go build -mod vendor -o sfom-server cmd/sfom-server/main.go
$> strings sfom-server
... greater than zeroconstant://?val={SENSITIVE_DATA}&decoder=stringdecoding string array or slice: length exceeds input...

In short there isn’t a good way to launch a locally run web application, automating the steps necessary to ensure secure and encrypted connections, from a native application. While it may be possible for the purposes of SFO Museum it’s not practical. Without the ability to ensure secure connections to the geotagging application then the potential for exposing sensitive credentials, whether it’s the OAuth2 application secret or an individual’s OAuth2 access token necessary for publishing data remotely, is real. The likelihood of that potential ever being abused may be low but that shouldn’t be our baseline.

A screenshot demonstrating macOS handling a request to open a native application, identified by the geotag://oauth2 URL, following a successful authentication flow with GitHub.

The approach we’ve taken instead is to a create a fully-fledged macOS desktop application that:

timetable: Air Canada, U.S. edition. Paper, ink. Gift of the William Hough Collection, SFO Museum Collection. 2009.122.037.

In part six of this series I wrote:

The important thing to understand is that writers are implemented in code and, in many cases, code that might be specific to your application. This is where go-www-geotag stops being a generic application designed for multiple uses and becomes a bespoke tool for a dedicated purpose. The reason for requiring this level of indirection (complexity, even) is that it allows the go-www-geotag application to be unconcerned with how data is output or where it is written to. The application expects to be given a valid writer.Writer instance and after parsing and validating input data assumes the writer will be responsible for everything else.

This presents a challenge when we’re running our geotagging application inside of a macOS application. Until now, the user-facing, browser-side of the geotagging application would simply hand data off to the server-side of the application and expect a preconfigured “writer” to take care of publishing that data. Normally, the browser-side of the application never sees the result produced from the data it submits but now it needs to. More specifically it needs to see the data that may or may not have been reformatted but before it’s been published.

To account for this the go-www-geotag package now comes with a new writer.Writer called io://. Conceptually it is similar to the stdout:// writer described in part six except that it accepts a user-defined target, a Go language io.Writer instance, that data will be written to.

For example, the target might be the application’s server response instance which would allow the code submitting geotagging information to see the output of their request. To enable support for the io:// writer you’d start the geotagging application like this:

$> bin/server \
	-enable-editor=false \
	-enable-writer \
	-writer-uri io:// \
	-disable-writer-crumb
2020/05/14 13:51:27 Listening on http://localhost:8080	

The -disable-writer-crumb flag configures the application to skip cross-site request forgery checks which is useful for testing but shouldn’t ever be enabled in a production environment. There is also a new -enable-editor flag which defaults to true but can be disabled if you only want or need to submit geotagging data programatically.

These flags allow us to submit the test geotagging data included with the go-www-geotag package to the application’s /update endpoint, from the command line, and see the output of the io:// writer:

$> curl -s -X PUT -d@fixtures/test.geojson 'http://localhost:8080/update?id=151%2F194%2F984%2F9%2F1511949849.geojson'

{
  "type": "Feature",
  "geometry": {
    "type": "GeometryCollection",
    "geometries": [
      {
        "coordinates": [
          -122.36640930175783,
          37.61888804488137
        ],
        "type": "Point"
      },
      {
        "coordinates": [
          [
            -122.40979705757145,
            37.60170454665891
          ],
          [
            -122.41460335611261,
            37.614495404514365
          ]
        ],
        "type": "LineString"
      }
    ]
  },
  "properties": {
    "angle": 20,
    "bearing": -106.54919973541514,
    "distance": 4209.290541392863
  }
}

It looks exactly the same as the input data, which isn’t very exciting.

The concept of an io:// writer has also been extended to the Who’s On First go-writer family of packages so that when combined with the geotagging application’s whosonfirst:// writer, all of which were described in part seven, it becomes possible to capture the data that would normally be published to the sfomuseum-data-collection GitHub repository.

For example:

$> bin/server \
	-enable-editor=false \
	-enable-writer \
	-writer-uri 'whosonfirst://?writer={whosonfirst_writer}&reader={whosonfirst_reader}&update=1&update=1&source=sfomuseum' \
	-whosonfirst-reader-uri 'fs:///usr/local/data/sfomuseum-data-collection/data' \
	-whosonfirst-writer-uri 'io://' \
	-disable-writer-crumb
2020/05/14 13:53:11 Listening on http://localhost:8080

Now when I submit the same test geotagging data I get a very different response:

$> curl -s -X PUT -d@fixtures/test.geojson 'http://localhost:8080/update?id=151%2F194%2F984%2F9%2F1511949849.geojson'

{
  "id": 1511949849,
  "type": "Feature",
  "properties": {
    "geotag:angle": 20,
    "geotag:bearing": -106.54919973541514,
    "geotag:camera_latitude": 37.61888804488137,
    "geotag:camera_longitude": -122.36640930175783,
    "geotag:distance": 4209.290541392863,
    "geotag:target_latitude": 37.60809997558664,
    "geotag:target_longitude": -122.41220020684203,
    "src:alt_label": "geotag-fov",
    "src:geom": "sfomuseum",
    "wof:id": 1511949849,
    "wof:repo": "sfomuseum-data-collection"
  },
  "geometry": {"type":"Polygon","coordinates":[[[-122.36640930175783,37.61888804488137],[-122.41460335611261,37.614495404514365],[-122.40979705757145,37.60170454665891],[-122.36640930175783,37.61888804488137]]]}
}
{
  "id": 1511949849,
  "type": "Feature",
  "properties": {
    "date:cessation_lower": "1928-12-31",
    "date:cessation_upper": "1928-12-31",
    "date:inception_lower": "1928-01-01",
    "date:inception_upper": "1928-01-01",
    "edtf:cessation": "1928-12-31",
    "edtf:date": "1928",
    "edtf:inception": "1928-01-01",
    "geom:area": 0,
    "geom:bbox": "-122.366409,37.618888,-122.366409,37.618888",
    "geom:latitude": 37.61888804488137,
    "geom:longitude": -122.36640930175783,
    "geotag:angle": 20,
    "geotag:camera_latitude": 37.61888804488137,
    "geotag:camera_longitude": -122.36640930175783,
    "geotag:target_latitude": 37.60809997558664,
    "geotag:target_longitude": -122.41220020684203,
    "iso:country": "XX",
    "lbl:latitude": 37.61888804488137,
    "lbl:longitude": -122.36640930175783,
    "millsfield:category_id": 1511214209,
    "millsfield:collection_id": 1511214207,
    "millsfield:subcategory_id": 1511213801,
    "mz:hierarchy_label": 1,
    "mz:is_current": -1,
    "sfomuseum:accession_number": "2011.032.0095",
    "sfomuseum:category": "Photograph",
    "sfomuseum:collection": "Aviation Archive",
    "sfomuseum:creditline": "Transfer",
    "sfomuseum:date": "1928",
    "sfomuseum:daterange_end": "1928-12-31",
    "sfomuseum:daterange_start": "1928-01-01",
    "sfomuseum:description": "From attached handwritten note: “02842 / 4-24-28 Concrete apron\". Black and white photographic negative of workmen on wooden walkway laying concrete in front of hangar No 2; rebar covering most of foreground; photograph taken on April 24, 1928. Inscribed on black border, bottom: “A 1055 4-24-28 MUNI. AIRPORT CONCRETE APRON.”",
    "sfomuseum:media_ids": [
      1527821365
    ],
    "sfomuseum:medium": "nitrate negative",
    "sfomuseum:object_id": 117614,
    "sfomuseum:placetype": "object",
    "sfomuseum:primary_media_id": 1527821365,
    "sfomuseum:subcategory": "Negative",
    "src:geom": "sfomuseum",
    "src:geom_alt": [
      "geotag-fov"
    ],
    "wof:belongsto": [
      1511214207, 
      85633793, 
      85922583, 
      85688637, 
      102191575, 
      102087579, 
      1511213801, 
      1511214209, 
      1511949849, 
      1511214277, 
      102527513
    ],
    "wof:breaches": [],
    "wof:country": "XX",
    "wof:created": 1579906683,
    "wof:geomhash": "f88235bd9b14840b6d3467f110064aa8",
    "wof:hierarchy": [
      {
        "arcade_id": 1511213801,
        "building_id": 1511214277,
        "campus_id": 102527513,
        "concourse_id": 1511214209,
        "continent_id": 102191575,
        "country_id": 85633793,
        "county_id": 102087579,
        "locality_id": 85922583,
        "neighbourhood_id": -1,
        "region_id": 85688637,
        "venue_id": 1511949849,
        "wing_id": 1511214207
      }
    ],
    "wof:id": 1511949849,
    "wof:lastmodified": 1589490308,
    "wof:name": "negative: Mills Field Municipal Airport of San Francisco, apron construction",
    "wof:parent_id": 1511214277,
    "wof:placetype": "venue",
    "wof:repo": "sfomuseum-data-collection",
    "wof:superseded_by": [],
    "wof:supersedes": [],
    "wof:tags": []
  },
  "bbox": [
    -122.36640930175783, 
    37.61888804488137, 
    -122.36640930175783, 
    37.61888804488137
  ],
  "geometry": {"coordinates":[-122.36640930175783,37.61888804488137],"type":"Point"}
}

Although the above looks like well-formed structured data it’s actually just plain text. This is because:

It would be more useful to get back data serialized as a valid GeoJSON FeatureCollection object so another writer, called featurecollection-io:// , has been added to the go-www-geotag-whosonfirst package which implements this functionality. For example:

$> bin/server
	-enable-editor=false \  
	-enable-writer \
	-writer-uri 'whosonfirst://?writer={whosonfirst_writer}&reader={whosonfirst_reader}&update=1&update=1&source=sfomuseum' \
	-whosonfirst-reader-uri 'fs:///usr/local/data/sfomuseum-data-collection/data' \
	-whosonfirst-writer-uri 'featurecollection-io://?count_features=2' \
	-disable-writer-crumb

Submitting the same test geotagging data I get back a valid GeoJSON document that I can query using the jq tool:

$> curl -s -X PUT -d@fixtures/test.geojson 'http://localhost:8080/update?id=151%2F194%2F984%2F9%2F1511949849.geojson' | jq '.features[].properties."wof:id"'

1511949849	// alternate geometry "field of view" WOF record
1511949849	// principal WOF record

How do all of these changes get handled in our geotagging application, specifically SFO Museum’s geotagging application? To answer that question let’s start by looking at the command the native macOS application uses to launch the geotagging application:

$> server \
	-nextzen-apikey {NEXTZEN_APIKEY} \
	-enable-oembed \
	-oembed-endpoints 'https://millsfield.sfomuseum.org/oembed?url={url}' \	
	-enable-writer \
	-writer-uri 'whosonfirst://?writer={whosonfirst_writer}&reader={whosonfirst_reader}&update=1&source=sfomuseum' \
	-whosonfirst-reader-uri 'github://sfomuseum-data/sfomuseum-data-collection' \	
	-whosonfirst-writer-uri 'featurecollection-io://?count_features=2'
	-disable-writer-crumb
2020/05/14 15:44:48 Listening on http://localhost:8080

Note: The application is not actually launched with command line flags in case you’re wondering about the discussion of sensitive data above. All the application flags are set using equivalent environment variables but I’ve included command line flags here for demonstration purposes.

First, we’ve enabled the featurecollection-io:// writer so that the application’s JavaScript code can capture the transformation of geotagging data in to multiple Who’s On First records.

Second, the -disable-writer-crumb flag is set to account for an outstanding logic bug in the go-www-geotag code that modifies the writer output when it doesn’t need to.

Third, the code in the go-www-geotag-sfomuseum package appends SFO Museum specific JavaScript code to the application. This custom code is invoked after the default code in the go-www-geotag package and is where we can reconfigure the application for SFO Museum’s needs.

A screenshot of the SFO Museum geotagging application running inside a native macOS application web view.

For example, the following is an abbreviated version of an early version the sfomuseum.geotag.init.js library which adds a historical maps layer control, as described in part eight, and redefines the functionality of the application’s Save button.

window.addEventListener("load", function load(event){

    var map = geotag.maps.getMapById("map");

    var layers_control = new L.Control.Layers({
	catalog: sfomuseum.maps.catalog,
    });

    map.addControl(layers_control);

    var save = document.getElementById("writer-save");\
    save.onclick = function(e){

        var uri = document.body.getAttribute("data-geotag-uri", uri);
	var camera = geotag.camera.getCamera();	   
        var fov = camera.getFieldOfView();
	    
	var on_success = function(data){
		
	    var wk_webview = document.body.getAttribute("data-enable-wk-webview");

	    if ((wk_webview == "true") && (sfomuseum.webkit.isAuth())){
	        webkit.messageHandlers.publishData.postMessage(data);
	    }		
	};
	    
        var on_error = function(err){
	    console.log(err);
	};
	    
        geotag.writer.write_geotag(uri, fov, on_success, on_error);
	return false;
    };
});

In the SFO Museum application the Save button works almost exactly the same as the default geotagging application except that it takes the response of the call to geotag.writer.write_geotag method and passes it off to something called webkit.messageHandlers.publishData.postMessage.

Earlier I said that we were using “the WKScriptMessage protocols to communicate between the geotagging application and the macOS application”. When a macOS (or iOS) application creates an embedded web view it can be configured to allow the exchange of messages between the view and larger application and this is an example of that messaging. We’re sending data from our web application back up to the native macOS application.

An early screenshot demonstrating using the WKScriptMessage protocols to relay messages from the native application to the geotagging web application.

Importantly those messages can only be exchanged via the embedded web view. Even if the geotagging application, started as an external process by the macOS application, were opened in a web browser, clicking the Save button would trigger an error because the webkit.messageHandlers.publishData.postMessage function won’t exist. It is only available in the context of the native application’s web view.

A screenshot illustrating where, in the native macOS application, geotagging data sent from the web application is processed.

We began writing a geotagging application by assuming all the publications details would be handled a writer.Writer instance, specifically the go-writer-github writer. Because we aren’t confident about being able to make authenticated calls securely to and from a locally run geotagging application, for all the reasons discussed above and in the last post we need to reimplement that functionality, specifically publishing the data to GitHub using an OAuth2 access token, in one of two ways:

In order to accomodate this new reality the application is configured to use a writer of type whosonfirst://?writer=featurecollection-io whose response is a GeoJSON FeatureCollection that is parsed and validated in custom code, whether its using JavaScript (in the geotagging application) or Swift (in the native macOS application). We’ve chosen to do the former assumeing that the GitHub OAuth2 access token is relayed to the JavaScript code via its container macOS application or set as a command line option which makes developing and debugging the code for the geotagging application a little easier.

A screenshot illustrating geotagging information being transformed in to a pair of Who's On First formatted documents and posted the sfomuseum-data-collection GitHub repository using the GitHub API.

The important change that bundling our go-www-geotag-sfomuseum application in a native macOS application introduces the need to write, or rewrite, code to handle publishing the data. It would be nice if we didn’t have to reimplement that code but it feels like an acceptable trade-off given all the other concerns. Crucially, the web application developed in Go that we’ve been discussing throughout this series of posts hasn’t changed or suffered a loss in functionality.

Although it is very early days for a native geotagging application that we can distribute to staff what I like about our approach is how we’ve begun to settle on a model that allows us:

This is what I meant earlier when I said:

In the same way that we can wrap a traditional web application in a Go program, can we wrap that Go program in a native macOS application? Each platform has its own unique affordances and tolerances. A larger goal for the museum is recognizing the possibilities that each platform affords so that we might be able to treat them as a kind of “kit of parts” to be reconfigured as needed for future projects.

We’ve published the source code for our native macOS wrapper application on the SFO Museum GitHub account and we would welcome any feedback, suggestions or improvements. It lacks any kind of polish and is still very much geared for the needs of SFO Museum but with a little bit of work could probably be made to work with any go-www-geotag compatible application.

A screenshot illustrating the native macOS application successfully publishing geotagging information for a photo of a 747 lifting off from SFO.

An obvious limitation to our approach so far is the lack of support for iOS or any non-Apple platforms. It’s not an ideal situation but on balance it’s one that SFO Museum can live with for the time being. If necessary the core web application can always be deployed as an online resource for people who can’t or don’t want to use a native macOS application.

We could probably also build an iOS version of this application pretty easily by deploying the same geotagging application server binary that is bundled with the macOS application to the web and pointing the iOS application’s embedded web view at that URL. This is already possible in the macOS application where we can point to a separate instance of the geotagging application, running locally, which is handy during the development phase.

        guard let server_uri = Bundle.main.object(forInfoDictionaryKey: "ServerURI") as? String else {
            NotificationCenter.default.post(name: Notification.Name("serverError"), object: ServerError.missingServerURI)
            return
        }
                
        let server_url = server_uri
        let url = URL (string: server_url)
        
        let use_local_server = Bundle.main.object(forInfoDictionaryKey: "UseLocalServer") as? String
        
        if use_local_server == nil || use_local_server != "YES" {
            NotificationCenter.default.post(name: Notification.Name("serverListening"), object: url!)
            return
        }

In the next and final post in this series I will discuss why I think it’s important to take this sort of layered (and sometimes laborious) approach to developing applications both in the context of SFO Museum and the broader cultural heritage sector.