• Introducing Tiny Storage: a small, lightweight UserDefaults replacement

    October 8, 2024

    A cute, purple floppy disk style animated character with their hands in the air with 'Tiny Storage' to their right
    Hey I'm a developer not an artist

    Following my last blog post about difficulties surrounding UserDefaults and edge cases that lead to data loss (give it a read if you haven’t, it’s an important precursor to this post!), I wanted to build something small and lightweight that would serve to fix the issues I was encountering with UserDefaults and thus TinyStorage was born! It’s open source so you can use it in your projects too if would like.

    GitHub link 🐙

    Overview

    As mentioned in that blog post, UserDefaults has more and more issues as of late with returning nil data when the device is locked and iOS “prelaunches” your app, leaving me honestly sort of unable to trust what UserDefaults returns. Combined with an API that doesn’t really do a great job of surfacing whether it’s available, you can quite easily find yourself in a situation with difficult to track down bugs and data loss. This library seeks to address that fundamentally by not encrypting the backing file, allowing more reliable access to your saved data (if less secure, so don’t store sensitive data), with some niceties sprinkled on top.

    This means it’s great for preferences and collections of data like bird species the user likes, but not for sensitive details. Do not store passwords/keys/tokens/secrets/diary entries/grammy’s spaghetti recipe, anything that could be considered sensitive user information, as it’s not encrypted on the disk. But don’t use UserDefaults for sensitive details either as UserDefaults data is still fully decrypted when the device is locked so long as the user has unlocked the device once after reboot. Instead use Keychain for sensitive data.

    As with UserDefaults, TinyStorage is intended to be used with relatively small, non-sensitive values. Don’t store massive databases in TinyStorage as it’s not optimized for that, but it’s plenty fast for retrieving stored Codable types. As a point of reference I’d say keep it under 1 MB.

    This reliable storing of small, non-sensitive data (to me) is what UserDefaults was always intended to do well, so this library attempts to realize that vision. It’s pretty simple and just a few hundred lines, far from a marvel of filesystem engineering, but just a nice little utility hopefully!

    (Also to be clear, TinyStorage is not a wrapper for UserDefaults, it is a full replacement. It does not interface with the UserDefaults system in any way.)

    Features

    • Reliable access: even on first reboot or in application prewarming states, TinyStorage will read and write data properly
    • Read and write Swift Codable types easily with the API
    • Similar to UserDefaults uses an in-memory cache on top of the disk store to increase performance
    • Thread-safe through an internal DispatchQueue so you can safely read/write across threads without having to coordinate that yourself
    • Supports storing backing file in shared app container
    • Uses NSFileCoordinator for coordinating reading/writing to disk so can be used safely across multiple processes at the same time (main target and widget target, for instance)
    • When using across multiple processes, will automatically detect changes to file on disk and update accordingly
    • SwiftUI property wrapper for easy use in a SwiftUI hierarchy (Similar to @AppStorage)
    • Can subscribe to to TinyStorage.didChangeNotification in NotificationCenter, and includes the key that changed in userInfo
    • Uses OSLog for logging
    • A function to migrate your UserDefaults instance to TinyStorage

    Limitations

    Unlike UserDefaults, TinyStorage does not support mixed collections, so if you have a bunch of strings, dates, and integers all in the same array in UserDefaults without boxing them in a shared type, TinyStorage won’t work. Same situation with dictionaries, you can use them fine with TinyStorage but the key and value must both be a Codable type, so you can’t use [String: Any] for instance where each string key could hold a different type of value.

    Installation

    Simply add a Swift Package Manager dependency for https://github.com/christianselig/TinyStorage.git

    Usage

    First, either initialize an instance of TinyStorage or create a singleton and choose where you want the file on disk to live. To keep with UserDefaults convention I normally create a singleton for the app container:

    extension TinyStorage {
        static let appGroup: TinyStorage = {
            let appGroupID = "group.com.christianselig.example"
            let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)!
            return .init(insideDirectory: containerURL)
        }()
    }
    

    (You can store it wherever you see fit though, in URL.documentsDirectory is also an idea for instance!)

    Then, decide how you want to reference your keys, similar to UserDefaults you can use raw strings, but I recommend a more strongly-typed approach, where you simply conform a type to TinyStorageKey and return a var rawValue: String and then you can use it as a key for your storage without worrying about typos. If you’re using something like an enum, making it a String enum gives you this for free, so no extra work!

    After that you can simply read/write values in and out of your TinyStorge instance:

    enum AppStorageKeys: String, TinyStorageKey {
        case likesIceCream
        case pet
        case hasBeatFirstLevel
    }
    
    // Read
    let pet: Pet? = TinyStorage.appGroup.retrieve(type: Pet.self, forKey: AppStorageKeys.pet)
    
    // Write
    TinyStorage.appGroup.store(true, forKey: AppStorageKeys.likesIceCream)
    

    (If you have some really weird type or don’t want to conform to Codable, just convert the type to Data through whichever means you prefer and store that, as Data itself is Codable.)

    If you want to use it in SwiftUI and have your view automatically respond to changes for an item in your storage, you can use the @TinyStorageItem property wrapper. Simply specify your storage, the key for the item you want to access, and specify a default value.

    @TinyStorageItem(key: AppStorageKey.pet, storage: .appGroup)
    var pet: = Pet(name: "Boots", species: .fish, hasLegs: false)
    
    var body: some View {
        Text(pet.name)
    }
    

    You can even use Bindings to automatically read/write.

    @TinyStorageItem(key: AppStorageKeys.message, storage: .appGroup)
    var message: String = ""
    
    var body: some View {
        VStack {
            Text("Stored Value: \(message)")
            TextField("Message", text: $message)
        }
    }
    

    It also addresses some of the annoyances of @AppStorage, such as not being able to store collections:

    @TinyStorageItem(key: "names", storage: .appGroup)
    var names: [String] = []
    

    Or better support for optional values:

    @TinyStorageItem(key: "nickname", storage: .appGroup)
    var nickname: String? = nil // or "Cool Guy"
    

    You can also migrate from a UserDefaults instance to TinyStorage with a handy helper function:

    let keysToMigrate = ["favoriteIceCream", "appFontSize", "useCustomTheme", "lastFetchDate"]
    TinyStorage.appGroup.migrate(userDefaults: .standard, keys: keysToMigrate, overwriteIfConflict: true)
    

    (Read the migrate function documentation for more details.)

    If you want to migrate multiple keys manually or store a bunch of things at once, rather than a bunch of single store calls you can consolidate them into one call with bulkStore which will only write to disk the once:

    let item1 = TinyStorageBulkStoreItem(key: AppGroup.pet, value: pet)
    let item2 = TinyStorageBulkStoreItem(key: AppGroup.theme, value: "sunset")
    
    TinyStorage.appGroup.bulkStore(items: [item1, item2])
    

    Hope it’s handy!

    If you like it or have any feedback let me know! I’m going to start slowly integrating it into Pixel Pals and hopefully solve a few bugs in the process.


  • Beware UserDefaults: a tale of hard to find bugs, and lost data

    October 5, 2024

    An iPhone sitting on an open book showing its Lock Screen.

    Excuse the alarmist title, but I think it’s justified, as it’s an issue that’s caused me a ton of pain in both support emails and actually tracking it down, so I want to make others aware of it so they don’t similarly burned.

    Brief intro

    For the uninitiated, UserDefaults (née NSUserDefaults) is the de facto iOS standard for persisting non-sensitive, non-massive data to “disk” (AKA offline). In other words, are you storing some user preferences, maybe your user’s favorite ice cream flavors? UserDefaults is great, and used extensively from virtually every iOS app to Apple sample code. Large amount of data, or sensitive data? Look elsewhere! This is as opposed to just storing it in memory where if the user restarts the app all the data is wiped out.

    It’s a really handy tool with a ton of nice, built-in things for you:

    • No needing to mess with writing to files yourself, and better yet, no need to coordinate when to persist values back to the disk
    • Easy to share data between your app’s main target and secondary targets (like a widget target)
    • Automatic serialization and deserialization: just feed in a String, Date, Int, and UserDefaults handles turning it into bytes and back from bytes
    • Thread-safe!

    So it’s no wonder it’s used extensively. But yeah, keep the two limitations in mind that Apple hammers home:

    Okay, so what’s the problem

    Turns out, sometimes you can request your saved data back from UserDefaults and it… just won’t have it! That’s a pretty big issue for a system that’s supposed to reliably store data for you.

    This can amount to an even bigger issue that leads to permanent data loss.

    Imagine a situation where a user has been meticulously opening your app for 364 days in a row. On day 365, your app promised a cool reward! When the user last closed the app, you stored 364 to UserDefaults.

    The user wakes up on day 365, excited for their reward:

    1. App launches
    2. App queries UserDefaults for how many days in a row the user has opened the app
    3. App returns 0 (UserDefaults is mysteriously unavailable so its API returns the default integer value of 0)
    4. It’s a new day, so you increment that value by 1, so that 0 changes to 1
    5. Save that new value back to UserDefaults

    Now, instead of your user having a fun celebration, their data has been permanently overwritten and reset! They are having a Sad Day™.

    It basically means, if at any point you trust UserDefaults to accurately return your data (which you know, sounds like a fair assumption) you might just get incorrect data, which you then might make worse by overwriting good data with.

    And remember, you’re not meant to store sensitive data in UserDefaults, but even if it’s not sensitive data it might be valuable. The user’s day streak above is not sensitive data that would be bad if leaked online like a password, but it is valuable to that user. In fact I’d argue any data persisted to the disk is valuable, otherwise you wouldn’t be saving it. And you should be always be able to trust an API to reliably save your data.

    What??? How is this happening? 😵‍💫

    As I understand it, there’s basically two systems coming together (and working incorrectly, if you ask me) to cause this:

    1. Sensitive data encryption

    When using Keychain or files directly, as a developer you can mark data that should be encrypted until the device is unlocked by Face ID/Touch ID/passcode. This way if you’re storing a sensitive data like a token or password on the device, the contents are encrypted and thus unreadable until the device is unlocked.

    This meant if the device was still locked, and you, say, had a Lock Screen Widget that performed an API request, you would have to show placeholder data until the user unlocked the device, because the sensitive data, namely the user’s API token, was encrypted and unable to be used by the app to fetch and show data until the user unlocked the device. Not the end of the world, but something to keep in mind for secure data like API tokens, passwords, secrets, etc.

    2. Application prewarming

    Starting with iOS 15, iOS will sometimes wake up your application early so that when a user launches it down the road it launches even quicker for them, as iOS was able to do some of the heavy lifting early. This is called prewarming. Thankfully per Apple, your application doesn’t fully launch, it’s just some processes required to get your app working:

    Prewarming executes an app’s launch sequence up until, but not including, when main() calls UIApplicationMain(::::).

    Okay, so what happened with these two?

    It seems at some point, even though UserDefaults is intended for non-sensitive information, it started getting marked as data that needs to be encrypted and cannot be accessed until the user unlocked their device. I don’t know if it’s because Apple found developers were storing sensitive data in there even when they shouldn’t be, but the result is even if you just store something innocuous like what color scheme the user has set for your app, that theme cannot be accessed until the device is unlocked.

    Again, who cares? Users have to unlock the device before launching my app, right? I thought so too! It turns out, even though Apple’s prewarming documentation states otherwise, developers have been reporting for years that that’s just wrong, and your app can effectively be fully launched at any time, including before the device is even unlocked.

    Combining this with the previous UserDefaults change, you’re left with the above situation where the app is launched with crucial data just completely unavailable because the device is still locked.

    UserDefaults also doesn’t make this clear at all, which it could do by for instance returning nil when trying to access UserDefaults.standard if it’s unavailable. Instead, it just looks like everything is as it should be, except none of your saved keys are available anymore, which can make your app think it’s in a “first launch after install” situation.

    The whole point of UserDefaults is that it’s supposed to reliably store simple, non-sensitive data so it can be accessed whenever. The fact that this has now changed drastically, and at the same time your app can be launched effectively whenever, makes for an incredibly confusing, dangerous, and hard to debug situation.

    And it’s getting worse with Live Activities

    If you use Live Activities at all, the cool new API that puts activities in your Dynamic Island and Lock Screen, it seems if your app has an active Live Activity and the user reboots their device, virtually 100% of the time the above situation will occur where your app is launched in the background without UserDefaults being available to it. That means the next time your user actually launches the app, if at any point during your app launching you trusted the contents of UserDefaults, your app is likely in an incorrect state with incorrect data.

    This bit me badly, and I’ve had users email me over time that they’ve experienced data loss, and it’s been incredibly tricky to pinpoint why. It turns out it’s simply because the app started up, assuming UserDefaults would return good data, and when it transparently didn’t, it would ultimately overwrite their good data with the returned bad data.

    I’ve talked to a few other developers about this, and they’ve also reported random instances of users being logged out or losing data, and after further experimenting been able to now pinpoint that this is what caused their bug. It happened in past apps to me as well (namely users getting signed out of Apollo due to a key being missing), and I could never figure out why, but this was assuredly it.

    If you’ve ever scratched your head at a support email over a user’s app being randomly reset, hopefully this helps!

    I don’t like this ☹️

    I can’t overstate what a misstep I think this was. Security is always a balance with convenience. Face ID and Touch ID strike this perfectly; they’re both ostensibly less secure per Apple’s own admission than, say, a 20 digit long password, but users are much more likely to adopt biometric security so it’s a massive overall win.

    Changing UserDefaults in this way feels more on the side of “Your company’s sysadmin requiring you to change your password every week”: dubious security gains at the cost of user productivity and headaches.

    Further, it’s not as if UserDefaults is truly secure with this change. It’s only encrypted between the time the device is rebooted and first unlocked. So sure, if you shut down your phone and someone powers it back up, the name of your pet cow might be knowable to the attacker, but literally any time after you unlock the device after first boot, even if you re-lock it, Mr. Moo will be unencrypted.

    Update: After more investigating it seems like this change has been around for years and years, but the results of it have just has gotten more prevalent with things like prewarming and Live Activities.

    I mean no shade to the nice folks at Apple working on UserDefaults, I’m sure there’s a lot of legacy considerations to take into account that I cannot fathom that makes this a complex machine. I’m just sad that this lovely, simple API has some really sharp edges in 2024.

    But enough moaning, let’s fix it.

    Solution 1

    Because iOS is now seemingly encrypting UserDefaults, the easiest solution is to check UIApplication.isProtectedDataAvailable and if it returns false, subscribe to NotificationCenter for when protectedDataDidBecomeAvailableNotification is fired. This was previously really useful for knowing when Keychain or locked files were accessible once the device was unlocked, but it now seemingly applies to UserDefaults (despite not being mentioned anywhere in its documentation or UserDefault’s documentation 🙃).

    I don’t love this solution, because it effectively makes UserDefaults either an asynchronous API (“Is it available? No? Okay I’ll wait here until it is.”), or one where you can only trust its values sometimes, because unlike the Keychain API for instance, UserDefaults API itself does not expose any information about this when you try to access it when it’s in a locked state.

    Further, some developers have reported UserDefaults still being unavailable even once isProtectedDataAvailable returns true, presumably where (for one reason or another) UserDefaults isn’t reading the disk store back into memory even though the file became available.

    Solution 2

    For the mentioned reasons, I don’t really like/trust Solution 1. I want a version of UserDefaults that acts like what it says on the tin: simply, quickly, and reliably retrieve persisted, non-sensitive values. This is easy enough to whip up ourselves, we just want to keep in mind some of the things UserDefaults handles nicely for us, namely thread-safety, shared between targets, and an easy API where it serializes data without us having to worry about writing to disk. Let’s quickly show how we might approach some of this.

    UserDefaults is fundamentally just a plist file stored on disk that is read into memory, so let’s create our own file, and instead of marking it as requiring encryption like iOS weirdly does, we’ll say that’s not required:

    // Example thing to save
    let favoriteIceCream = "chocolate"
    
    // Save to your app's shared container directory so it can be accessed by other targets outside main
    let appGroupID = ""
    
    // Get the URL for the shared container
    guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else {
        fatalError("App Groups not set up correctly")
    }
    
    // Create the file URL within the shared container
    let fileURL = containerURL.appendingPathComponent("Defaults")
        
    do {
        let data = favoriteIceCream.data(using: .utf8)
        try data.write(to: fileURL)
    
        // No encryption please I'm just storing the name of my digital cow Mister Moo
        try FileManager.default.setAttributes([.protectionKey: .none], ofItemAtPath: fileURL.path)
        print("File saved successfully at \(fileURL)")
    } catch {
        print("Error saving file: \(error.localizedDescription)")
    }
    

    (Note that you could theoretically modify the system UserDefaults file in the same way, but Apple documentation recommends against touching the UserDefaults file directly.)

    Next let’s make it thread safe by using a DispatchQueue.

    private static let dispatchQueue = DispatchQueue(label: "DefaultsQueue")
    
    func retrieveFavoriteIceCream() -> String? {
       return dispatchQueue.sync { 
          guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "app-group-id") else { return nil }
    
          let fileURL = containerURL.appendingPathComponent(fileName)
                
          do {
             let data = try Data(contentsOf: fileURL)
             return String(data: data, encoding: .utf8)
          } catch {
             print("Error retrieving file: \(error.localizedDescription)")
             return nil
          }
       }
    }
    
    func save(favoriteIceCream: String) {
       dispatchQueue.sync { 
          guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "app-group-id") else { return }
    
          let fileURL = containerURL.appendingPathComponent(fileName)
    
          do {
             let data = favoriteIceCream.data(using: .utf8)
             try data.write(to: fileURL)
             try FileManager.default.setAttributes([.protectionKey: .none], ofItemAtPath: fileURL.path)
             print("File saved successfully at \(fileURL)")
          } catch {
             print("Error saving file: \(error.localizedDescription)")
          }
       }
    }
    

    (You probably don’t need a concurrent queue for this, so I didn’t.)

    But with that we have to worry about data types, let’s just make it so long as the type conforms to Codable we can save or retrieve it:

    func saveCodable(_ codable: Codable, forKey key: String) {
        do {
            let data = try JSONEncoder().encode(codable)
            // Persist raw data bytes to a file like above
        } catch {
            print("Unable to encode \(codable): \(error)")
        }
    }
    
    func codable<T: Codable>(forKey key: String, as type: T.Type) -> T? {
        let data = // Fetch raw data from disk as done above
        
        do {
            return try JSONDecoder().decode(T.self, from: data)
        } catch {
            print("Error decoding \(T.self) for key \(key) with error: \(error)")
            return nil
        }
    }
    
    // Example usage:
    let newFavoriteIceCream = "strawberry"
    saveCodable(newFavoriteIceCream, forKey: "favorite-ice-cream")
    
    let savedFavoriteIceCream = codable(forKey: "favorite-ice-cream", as: String.self)
    

    Put those together, wrap it in a nice little library, and bam, you’ve got a UserDefaults replacement that acts as you would expect. In fact if you like the encryption option you can add it back pretty easily (don’t change the file protection attributes) and you could make it clear in the API when the data is inaccessible due to the device being locked, either by throwing an error, making your singleton nil, awaiting until the device is unlocked, etc.

    Solution 3

    Update! I wrote a little library to kinda wrap all of the above into a handy little tool! It’s called TinyStorage.

    End

    Maybe this is super obvious to you, but I’ve talked to enough developers where it wasn’t, that I hope in writing this it can save you the many, many hours I spent trying to figure out why once in a blue moon a user would be logged out, or their app state would look like it reset, or worst of all: they lost data.


  • Juno for YouTube has been removed from the App Store

    October 1, 2024

    Screenshot from App Store Connect showing Juno as 'Removed from App Store'

    For those not aware, a few months ago after reaching out to me, YouTube contacted the App Store stating that Juno does not adhere to YouTube guidelines and modifies the website in a way they don’t approve of, and alludes to their trademarks and iconography.

    I don’t personally agree with this, as Juno is just a web view, and acts as little more than a browser extension that modifies CSS to make the website and video player look more “visionOS” like. No logos are placed other than those already on the website, and the “for YouTube” suffix is permitted in their branding guidelines. Juno also doesn’t block ads in any capacity, for the curious.

    I stated as much to YouTube, they wouldn’t really clarify or budge any, and as a result of both parties not being able to come to a conclusion I received an email a few minutes ago from Apple that Juno has been removed from the App Store.

    Juno was a fun hobby project for me to build. As a developer I wanted to get some experience building for the Vision Pro, and as a user I wanted a nice way to watch YouTube on this cool new device. As a result I really enjoyed building Juno, but it was always something I saw as fundamentally an app I built for fun.

    Because of that, I have zero desire to spin this into a massive fight (at least more than I’ve fought in emails over the last few months) akin to what happened with Reddit years ago. That’s kind of the opposite of fun. I hope that’s understandable.

    For those who have Juno, to my knowledge it should continue to work fine until/unless the YouTube website updates in some fashion that breaks stuff. Sorry it had to end this way, I had some really cool stuff planned for it that I think would have been a lot of fun! It’s been genuinely awesome hearing all the kind words from Vision Pro users who have loved the app.

    🫡


  • Server side Live Activities guide

    September 23, 2024

    A Live Activity on an iOS Lock Screen with an emoji and the text 'I sent this from a server woooooo'.

    iOS 17.2 gained the capability to start Live Activities from a server, which is pretty cool and handy! I’ve been playing around with it a bit and found some parts a bit confusing, so I thought I’d do a little write up for future me as well as anyone else who could benefit!

    (For the uninitiated, Live Activities are the cool iOS feature that adds a live view of a specific activity to your Dynamic Island (if available) and Lock Screen, for example to see how your Uber Eats order is coming along.)

    Overview of starting a Live Activity server-side

    It’s pretty straightforward, just a few steps:

    Get token

    In your AppDelegate’s didFinishLaunching or somewhere very early in the lifecycle, start an async sequence to listen for a pushToStartToken so you can get the token:

    Task {
        for await pushToken in Activity<IceCreamWidgetAttributes>.pushToStartTokenUpdates {
            let pushTokenString = pushToken.reduce("") { $0 + String(format: "%02x", $1) }
            print("Our push token is: \(pushTokenString)")
        }
    }
    

    Use token to start Live Activity server-side

    Now that you have the token, we use that to send a push notification (using APNs) to start the Live Activity on the user’s device. There’s lots of server side libraries for this, or you can just use curl, or you can an online site to test like this one (in the case of the latter where you’re uploading your token to random sites, please create a sample token that you delete afterward).

    The key points that differ from sending a “normal” push notification:

    Headers

    • Set your push token to the pushToStartToken you received above
    • Set the topic to your app’s normal bundle ID with an added suffix of .push-type.liveactivity
    • Set the priority to 5 for low priority or 10 for a high priority notification (does not seem like any values in between work)
    • Set apns-push-type to liveactivity

    Payload

    • timestamp field with the current unix timestamp
    • event field set to start
    • attributes-type set to the name of your Swift Live Activities attributes struct
    • attributes with a dictionary representing your Swift Live Activity attributes
    • content-state with the initial content state as a dictionary, similar to attributes
    • alert field set with a title and a body (will silently fail if you just set alert to a string like the old days)

    Note that you cannot use the old certificate based authentication and instead have to use token based authentication and http2.

    Send the http request and it should start the Live Activity on the iOS device! 🎉

    Aside

    Sending push notifications is kinda complicated, so you likely want a server-side library. I wanted to play around with Node for this for the first time, and in case you go down that path, in September 2024 Node is in a weirdly lacking spot for APNs libraries. The de facto one is abandoned, the community replacement for it doesn’t work with TypeScript, and there’s a third option with TypeScript support but it isn’t super popular and has some issues. I ended up going back to Go, and there’s an excellent APNs library there.

    It’s broken on iOS 17

    On iOS 17, getting the above push token is really hard (you can seemingly only get it once). I tried for ages to get the token before stumbling upon a thread on the Apple forums where a user said to delete the app, reboot your device, then fresh install it. Sure enough that worked and the token displayed, but if I just rebooted the device, or just reinstalled the app, it wouldn’t. Had to do all of them. And no it didn’t change if I used a release configuration.

    I tried this on iOS 17.6.1 (latest iOS 17 release at the time of writing). It does not seem to be an issue at all on iOS 18.

    The difficulty in acquiring it makes it incredibly hard to use on iOS 17 if you add in the feature in an update and the user isn’t getting your app from a fresh install, to the extent that I can’t really trust its reliability on iOS 17 as a feature you could advertise, for instance.

    John Gruber recently wrote about the Apple Sports app and wondered why its Live Activities feature is iOS 18 only. A reader wrote in to mention the new broadcast push notifications feature requiring iOS 18, and that well may be it, but I’d say it’s equally as likely that it just doesn’t work reliably enough on iOS 17 for even Apple to bother.

    Update/end the activity

    This part admittedly confused me a bit. The docs state:

    • Send the push-to-start token to your server and use the pushToStartTokenUpdates sequence to receive token updates. Similar to the update token, update it on your server when needed and invalidate the old token.
    • While the system starts the new Live Activity and wakes up your app, you receive the push token you use for updates. To update and end the Live Activity on devices that aren’t running iOS 18 or iPadOS 18, use this update push token as if you obtained it by starting a Live Activity from within your app.

    I assumed that this all operated through that same pushToStartTokenUpdates, because as soon as you start the activity server-side, your app wakes up, and your pushToStartTokenUpdates async sequence fires again with a “new” token.

    However the “new” token is just the same one that you started the activity with, and if you try to end your activity server-side with this token, nothing happens.

    Turns out, your pushToStartTokenUpdates is (per the name!) only able to start Live Activities. Not sure why it fires a second time with the same token, but you do want to use that async sequence to monitor for changes to the start token, because it might change and the next time you want to start a new Live Activity you’ll need that token.

    To update/end your Live Activity, what you actually want to do is create a separate async sequence to monitor your app for Live Activities that get created, and then monitor its push token:

    // Listen for local Live Activity updates
    for await activity in Activity<IceCreamWidgetAttributes>.activityUpdates {
        // Upon finding one, listen for its push token (it is not available immediately!)
        Task {
            for await pushToken in activity.pushTokenUpdates {
                let pushTokenString = pushToken.reduce("") { $0 + String(format: "%02x", $1) }
                print("New activity detected with push token: \(pushTokenString)")
            }
        }
    }
    

    iOS will also call this async sequence on start of a new Live Activity from a server, and you use that token to update/end it.

    I’m not blaming the documentation on this, I understand it as it is written now, but I wanted to clarify in case anyone else gets confused.

    Once your server is made aware of this token, you can end your Live Activity server-side with the following changes from the above start considerations:

    • Payload: event should be end or update
    • Payload: If ending you might want a dismissal-date unix timestamp. If you don’t set this, iOS will immediately remove the Live Activity from the Dynamic Island but leave it on the Lock Screen for up to four hours. You may want this, but you can control how long it stays there by setting dismissal-date, if you set it to now or in the past it will remove it upon receipt of the notification.
    • Payload: Send a new content-state if updating (optional if ending, if ending and you want to leave it on the lock screen (see previous point) you can set a content-state which will serve as the final content state for the Live Activity)
    • Payload: Do not send attributes or attributes-type as these are intended to be immutable through the life of the Live Activity

    There’s other interesting ones that you might want to consider but aren’t as important, like stale-date, discussed in the docs.

    That’s it!

    That should cover most things! I want to thank the incredible Francesc Bruguera for helping me get unstuck a few places.


  • The Caldera: a sleek split and wireless keyboard

    July 20, 2024

    I designed my own keyboard and it’s freely available! I’m calling it the Caldera, and it’s basically my dream wireless split keyboard.

    I’ve been using it for months, and I love it.

    Video overview

    If you’re a visual person, I made a fun little video showcasing the creation process as well as an overview of the keyboard. It serves as a nice tutorial too if you’re even vaguely interested in building your own custom keyboard. I’ve been wanting to make some more videos going forward, and this was a fun way to get into it! If you’d like some more videos on things I’m planning to create, subscribe to my YouTube channel!

    Why and how?

    I love split keyboards, they’re so nice on your wrists when you type all day and have helped my wrist pain immensely, but none of the existing keyboards were quite what I wanted. They were either wired, had key layouts I didn’t vibe with, or were too “techy” looking. I wanted a layout with all the right keys, a sleek design, and fully wireless with long battery life.

    Of course there’s many resources for “building your own keyboard”, but they’re normally just an assembly kit for someone else’s keyboard, letting you change the switches or key caps. I wanted something fully custom, with the keys positioned exactly where I wanted them, and a clean design to boot. That means it needs a custom PCB.

    I learned about a cool piece of free, open source software called Ergogen that helps you to build your own completely custom keyboard and I got to work learning it. The process was surprisingly easy, and the video above goes into how to use it from a beginner’s perspective. The video also touches on KiCad, which Ergogen hands off the PCB to, and Fusion 360 which I use to design the case. Then you just have to flash some simple firmware on it from ZMK where you just tell it about your keyboard.

    Resources

    Everything that I built for this keyboard is available open source on GitHub, from the Ergogen files, to the PCB files, to the 3D printable case files, to the ZMK firmware files. It’s all honestly pretty easy! The 3D printable files specifically can be found here.

    If you want this keyboard for yourself, simply take the PCB files (the .zip gerber files on GitHub) and upload them to a PCB manufacturer (like JLCPCB or PCBWay, not sponsored by either), the PCBs will arrive, then it just requires a very beginner level amount of soldering (great first project honestly), some 3D printable cases, some standard keyboard supplies like keys and a controller, and then you just have to flash the firmware and you’ll have a functioning Caldera keyboard, or a basis for you to build your own completely custom keyboard! (It doesn’t have to be a split keyboard either!)

    Supplies

    Here are all the supplies needed for this project. It seems like a fair bit, but many are just great to have things that you can also use for other projects if you don’t have them already, and the rest are cheap just to grab for this project. None of these are affiliate links.

    General tools

    • Pinecil soldering iron
    • Bambu Lab P1S printer (can likely also have a friend or local business 3D print the case for you!)
    • Filament (I used Bambu’s white marble PLA)
    • Kotto fume extractor (optional but highly suggested, please don’t breathe in solder fumes!)
    • Solder (I use 0.6mm and recommend lead-free for health and environmental reasons)
    • Tweezers (idk pick some up at a pharmacy if you don’t have any weirdo)

    Keyboard specific things

    I get most of these from Typeractive (except the PCB), makes one order easy, and has free shipping (not sponsored).

    • PCBs (see above)
    • Choc low profile key switches of your choice (I like Choc Pro Reds personally, Typeractive has them)
    • nice!nano controller x2 (Typeractive)
    • Controller hot swap sockets (Typeractive)
    • PS3 controller replacement battery (Amazon)
    • Battery input jack (Typeractive)
    • Diodes (Typeractive)
    • Reset button (Typeractive)
    • Kailh Choc hot swap sockets (Typeractive)
    • Key caps (I like Choc MBK key caps, which Typeractive has)
    • Battery power switch (Typeractive)
    • 10x M2x3 screws, and 5x M2x4 standoffs for assembling each side of the case (so 20 screws and 10 standoffs in total) (I’d just grab a small kit on Amazon)
    • (Optional) 6mm (0.24") diameter x 2mm (0.08") height rubber feet for the bottom of the case

    Totalling up the cost of the project, I’d estimate you could put this keyboard together for under $200 (talking about consumables, not including tools that you may or may not already have).

    The Caldera keyboard on a kitchen island with bananas around it and a LEGO figurine of Aloy from Horizon Zero Dawn

    Enjoy!

    If you build the keyboard or have feedback on my video about building it, I’d love to know! Hit me up on Twitter/X, Mastodon, or Threads! It was a really fun process to learn and was a lot easier than I ever imagined it to be, so I’d definitely encourage you to go out and try if there’s a keyboard you wish existed!