Learning Swift Development for macOS by Building a Website Blocker
Tags: digital-minimalism, learning • Categories: Learning
I loved Focus App. It blocked websites and apps on a schedule. But, years ago it started glitching out: sucking up tons of ram and freezing my computer. They didn’t fix the bug and I abandoned using it and instead switched to a host-based blocking system which has served me well.
However, there are some issues with the host-based approach:
- I can’t block specific URLs, only hosts (focus app couldn’t do this either)
- I can’t set a schedule
- I can’t block apps
- If I remove a host it will not automatically get blocked unless I sleep and wake the computer
- Sleepwatcher (cli tool) is dead and requires some manual set up to get working.
My goal is to layer on top of the existing host-based system that has been working great and add another layer of focus tooling:
- CLI-first tool
- Allow configuration to be easily set using a JSON file
- Allow different blocking configuration to be scheduled
- Replace sleepwatcher by configuring script execution on wake
- Add a ‘first wake of the day’ trigger that I can tie into clean browsers and todoist scheduler
- Allow both hosts and partial match urls to be blocked
- ‘Partial match’ means (a) anchors are excluded and (b) the configured block url must only be a subset of the url on the browser in order to be blocked. This will enable things like blocking news or shopping search on google.
- Support blocking urls in google chrome and safari
- No UI, maybe build a simple REST API that could be tied into my beloved Raycast
- Run CLI tool as privileged (in order to mutate
/etc/hosts
)
With a clear goal in mind for this learning project, I was able to get started and build this out. Here are the two repos with the resulting code:
I haven’t touched macOS development in years and hadn’t done any Swift development before. Below are my notes from learning swift and macOS development.
Swift Language
- The
guard
statement is explicitly used to return early. It’s likeunless
in ruby with some special scoping properties. More info.- Specifically
guard
is useful for unwrapping an optional and assigning the unwrapped variable to something that can be used in the outer scope.
- Specifically
- There’s a community built package manager, but it requires that you (a) have a
Package.swift
and (b) use a specific source code structure. Both of which are a pain for a simple utility.- I found later on that it’s better to just set up your application using
Package.swift
, even if it’s small. You’ll end up needing a community package and using theswift
CLI tooling is nice.
- I found later on that it’s better to just set up your application using
- There’s a built-in JSON decoder, but it requires you to describe the incoming JSON payload as a struct. This makes sense since swift is strictly typed, but makes fiddling with data structures a PITA.
- There’s no built-in logging library with levels. There’s an open-source package out there, but not having it included with the stdlib is crazy to me. Here’s a < 50 line implementation of a simple
stdout
log. @objc
exposes the swift function/class to the objective-c side of the world. You don’t have to worry too much about this, the compiler will warn you and enforce that you put these attributes in the right places.- You can extend existing classes via
extension String
and add whatever methods you’d like onto them. I’m surprised by this for what seems an otherwise very structured language. This was a great compromise.- One of the guys who works on the Swift language built Rust. I don’t know Rust (it’s on my learning list!) but from what I’ve heard—and the adoption it’s gotten across the new CLI tooling that has been emerging—it’s an amazing language. Probably part of the reason Swift seems so well-designed.
- Doesn’t seem like there are union types in Swift. You have to define an enum and then unwrap the enum using a
switch
statement. This seems insane to be and makes for very ugly code, I must be missing something here. - You can nest struct definitions, which is nice.
- You can’t add a trailing comma to arrays or dicts, which drives me nuts. Makes it harder to refactor code and adds additional mental overhead to editing anything. It’s puzzling to me why more languages don’t allow this (one of the things I love about Ruby).
- You can typecast an object to a specific type with
as! SafariWindow
- I imagine, since Swift is strongly typed, this has some limitations + compile errors, but I don’t know what they are and didn’t bother to learn.
- You only need an
import
to pull in a framework, not individual files. All files in the project are automatically compiled. Anything marked withpublic
is available to everything in the project.- This seems to indicate something otherwise, still some more investigation needed here.
- Argument order matters even when using keyword arguments. Bummer.
- Crash reports are still nearly useless. They have a stack trace, but no line numbers. You need to convert the crash report into a stack trace which is usable, which requires symbol-mapping file (dSYM) generated at the same time as the binary that generated the crash report. PLCrashReporter does a lot of this for you, but for a simple single-file swift script this is a massive pain. There are no stack traces on the command line, even in debug mode.
!
asserts that the optional is not nil. If it is, your app will crash.- You can use
as?
to define a default value if a non-nil value does not exist - Method overloads exist, so you can define a method multiple times with different params. I really like this pattern, wish Swift had method guards like Elixir (one of my favorite things about Elixir).
- You have to explicitly indicate that a
func
could throw an exception withthrows
in the method signature. This is interesting, I think I like it, makes the design of the function more explicit. - Empty dictionary is
[:]
, and you can inline-typeAny
to a dictionary viavarName: [String: Any]
. I think Swift dictionaries are the same as anNSDictionary
under the hood. dispatchMain()
is not the same asRunLoop.main.run()
despite what some blog articles say.let
==const
in JavaScript,var
is roughly equivalent to JavaScript.- Multiple
let
statements in anif
can be separated by a comma. If any of the let statements results in a nil value, then the if statement fails. I don’t understand the value of this syntax above&&
. I don’t like this language design choice. - There are some magic variables. For instance, if you are in a
catch
block theerror
variable represents the exception. If you have a global function namederror
it is not accessible and overwritten by the localerror
variable. - I didn’t read up on Swift’s memory allocation strategy, but my assumption is if a var isn’t referenced any longer (i.e. out of scope) it’s removed/garbage collected. The foot gun here is you have a class which subscribed to a notification (
NSWorkspace.shared.notificationCenter.addObserver
) but that class is not assigned to a var that will continue to persist after the caller completes (i.e. a class or global variable) the object will be garbage collected and you’ll never receive that notification and an error will not be thrown.- However, if a function creates a
Task
which creates its own run loop, that task will continue to run as long as the loop is created even after the caller that created theTask
has completed. I would imagine this is a bad design pattern. - This also applies to other systems which receive ‘notifications’. I use this word very vaguely because I don’t understand macos subsystems very well/at all. It seems like there are ‘grand central dispatch’ queues which feel similar to a SQS queue, and those seem to be impacted as well. Any async pub/sub type interface would be impacted by the subscriber being garbage collected and you will not receive an error. It puzzles me why errors are not thrown.
- However, if a function creates a
Hosting a localhost server
This is simple as long as you do bind to a local IP: localhost
, 127.0.0.1
, etc. If you bind to your router’s IP address you’ll run into all sorts of permissioning issues:
- The default permissioning is different depending on what macos version you are on.
- Here’s an example of how to check an application’s default permissioning
- You cannot change your entitlements/permissions if you are just building a simple binary or cli app. You need an app with a
Info.plist
to set the proper security config. This is because of new security stuff that apple has introduced. -
- This means you need to use xcode to setup and build your application. I couldn’t find any good examples of an app that is built without using XCode.
- The alternative to this is using another layer of indirection, like tuist. This is bringing back memories of all of the stuff I hated about desktop application development.
- Don’t bind to the device IP (i.e. the wifi- or ethernet-assigned address) unless you need to. Bind to localhost so the server is only accessible on the device.
Swift server package options
- https://criollo.io
- https://github.com/httpswift/swifter
- https://github.com/Building42/Telegraph
- https://github.com/envoy/Ambassador
Packaging
Not using a Package.swift
for anything even slightly complex will bring a world of pain:
- The VS Code tooling doesn’t work as well (no error highlights and LSP stuff)
- You can’t use a package manager and therefore can’t easily pull in community packages
- Anything that uses
swift build
doesn’t work
You’ll want to use a Package.swift
in your project. Generating a Package.swift
is pretty easy:
swift package init --type executable
When running swift build
I ran into:
no such module 'PackageDescription
This post describes the issue and the following command fixes it for me:
sudo xcode-select --reset
If you run into issues with compilation errors due to some features not being available on older macos versions, you’ll need to add a platform requirement to your Package.swift
:
platforms: [
.macOS(.v13)
],
Here’s an example Package.swift for the CLI tool.
Cleaning All Cache
I ran into a very weird build error:
❯ swift run
Building for debugging...
Build complete! (0.25s)
dyld[21481]: Symbol not found: (_$s10Foundation11JSONDecoderC6decode_4fromxxm_AA4DataVtKSeRzlFTj)
Referenced from: '/Users/mike/Projects/focus-app/.build/x86_64-apple-macosx/debug/focus-app'
Expected in: '/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation'
[1] 21481 abort swift run
Even after resetting the project to a state where I knew it compiled, it still errored out. After walking away for a while, I found this post and tried updating the min macos version. It magically fixed the issue.
Here’s what I used to clear all build caches:
rm -Rf .build/
rm Package.resolved
rm -Rf ~/Library/Developer/Xcode/DerivedData
rm -Rf /Users/mike/Library/Caches/org.swift.swiftpm
Open Questions
- Is there a way to open a repl with your application’s code imported? It was nice that a compiled language had a recent repl, but ideally, I want to open a repl and be able to import/use my applications code.
- How is the debugger? I just did caveman debugging for this project and didn’t bother understanding the GUI debug tooling.
- It’s unclear how good the package ecosystem is. It seems better than my Cocoa days, but there weren’t that many options and the package activity seems pretty dead.
- It doesn’t seem like you can build a
.app
without an xcode project. This is annoying, especially if you are building a small tool and don’t want to learn and understand the xcode toolchain (it still seems terrible). I wonder if I’m missing something here and if there’s some good tooling to support a CLI-based application build? - I was surprised at how many errors were not reported. If you’ve subscribed an object as an observer to a notification center, the object was GC’d, that should give you an error. It seems like there were a good number of silent failures which made it harder to discover unexpected failures, especially to someone who is not a desktop developer. I wonder if there’s some env flags that change this behavior.
- I never understood/learned exactly what the
@
does in Swift. It looks like a JS/Python decorator, but it’s unclear if all of the annotations are owned by Swift or if developers can write their own. - Where is the documentation for all of the magic variables? i.e.
error
in acatch
block?
Open Source
- https://github.com/Ranchero-Software/NetNewsWire
- https://github.com/rxhanson/Rectangle Has automated some of the release process
- https://github.com/exelban/stats
- https://github.com/kean/PulsePro
- https://github.com/piemonte/Player
- https://github.com/cirruslabs/tart
- https://github.com/signalapp/Signal-iOS
- https://github.com/onevcat/Rainbow
- https://github.com/Sequel-Ace/Sequel-Ace
- https://github.com/HedvigInsurance/ugglan
- https://github.com/lvillani/chai
- https://github.com/halo/LinkLiar
Thoughts on Swift
Swift is a really nice language. I like how it is strongly typed, but the typing system is good at inferring types when it can, so you don’t have to specify that many types. The type inference seems very good—better than TypeScript, Sorbet, and python from what I can tell.
I don’t like how there are not any imports, and how anything marked as public can clutter the global namespace. I hate this about ruby, and it’s something I think python gets very right. I wish there would be explicit imports
and any package-level functions would be forced to be called with their package name. I can understand how this would get very messy with the objc stuff, but that could have been special-cased in some way.
Some of the objc interface stuff is strange, but I think the language designers did a very good job of dealing with it in a simple way.
The tooling isn’t bad but there are some strange gaps in the stdlib, largely because of the legacy cocoa infrastructure you can leverage. I found this annoying: there’s not a simple logger, there’s no built-in yaml parser, etc. The Cocoa apis have a lot of legacy decisions to deal with and they are generally a pain to use. I wish the stdlib was more expansive and designed without thinking about the legacy APIs too much.
The package manager requires you to build your application in a specific way, which is annoying, but if you follow the golden path things work in a pretty clean way. It’s nice that there is an official package manager that Apple is committed to maintaining.
After writing something simple in Swift, I found myself wishing JavaScript was Swift. It feels like JavaScript in many ways, but has less foot guns and is more simple. The language designers did a great job, and it felt fun to work in.