Swiftjective-C: Swift, iOS, SwiftUI, and Indie Development

Swiftjective-C is Jordan Morgan's independently published blog about building excellent software for Apple platforms. It includes practical Swift, SwiftUI, UIKit, accessibility, App Intents, architecture, design, and indie development articles, plus complete code samples and long-running notes from shipping iPhone, iPad, and Mac apps.

// Written by // // Read it in about 1 minute // RE: The Indie Dev Diaries

This post is brought to you by Clerk. Add secure, native iOS authentication in minutes with Clerk’s pre-built SwiftUI components.

For a long time, the homies and I have joked about starting a podcast.

Spoiler: we will never actually start a podcast. But, it would be fun though.

The idea of a podcast is tantilizing. I love talking shop, writing about indie life, or chatting with anyone about the ongoings of our insane vocation. And while I’ve written on this blog for years now, I do love talkin’.

So, introducing…

Indie Walks

Short form video essays/journals/episode/talk show??? from me over indie stuff. So, MRR, web to app, how to manage time as a parent with side projects, that kind of stuff. You can watch the first episode wherever is best for you:

  1. X
  2. Threads
  3. Instagram
  4. Episode Directory

And, as linked above, I’ll keep that evergreen page up to date here with each new episode. I hope you’ll follow along, and feel free to shoot over any questions or suggestions for topics.

Until next time ✌️

···
// Written by // // Read it in about 12 minutes // RE: AI

This post is brought to you by Clerk. Add secure, native iOS authentication in minutes with Clerk’s pre-built SwiftUI components.

Skills are a paramount puzzle piece to my agent usage. The rub? They are too easily fragmented:

  1. You could be using different agents. Claude, unlike other agents, looks at its own spot for skills. Everything else uses an open standard, ~/.agents/skills.
  2. Then, there’s different machines you could be running. Symlinking is great, but run npx skills update on your MacBook at home and then your MacBook on the go is now stale.
  3. Each harness seems to want to funnel you into different paths for installing skills.

Is there an open standard to solve? Surely, if there is, it would have to be cloud based. For example, at Superwall, we simply have GitHub repo for proprietary, shared skills. So, I’ve adopted that same pattern for two things:

  1. The skills I’ve personally made, and just as important…
  2. Skills I’ve installed via Vercel’s skills.sh.

The whole process hinges on three skills: push-my-skills, pull-my-skills, and sync-npx-skills. So, uhhh, naturally - I made a Windows XP recreation to show you how the process works. You got two computers, each needing to be current on 3rd party skills from skills.sh, and my own personal ones. Click on any of the skill buttons below to see how they operate:

Skills Sync Center

My Network Places Choose a job below to watch it travel.
At home

MacBook A

My source filespersonal-skills/
Agent copiesClaude · Codex · more
npx lockWhat is installed here
The handoff

GitHub

Skills I madeDreamingInBinary/skills
Third-party manifestDesired state + baselines
On the go

MacBook B

My source filespersonal-skills/
Agent copiesClaude · Codex · more
npx lockWhat is installed here
THE WHOLE IDEA

Two kinds of skills. One cloud handoff.

Your skills no longer depend on whichever Mac or agent you happened to use last.

  1. 1

    Skills you authorPush the real files up; pull them down elsewhere.

  2. 2

    Skills you installPublish the list; reconcile each Mac against it.

  3. Installed folders are copiesGitHub is the durable source of truth.

Ready. Pick a job to run.

Here’s the technical breakdown.

For the skills that I’ve made… …one GitHub repo is the source of truth; so everything inside ~/.agents/skills is merely an installed copy. On one Mac, push-my-skills refreshes those copies, commits my changes and pushes them to GitHub in one shot. On another, pull-my-skills pulls that repo and reinstalls the same skills for every agent. Now, my personal skills are source controlled and current across machines.

But, that’s just half of it. Third-party skills actually use a shared manifest instead…

…so sync-npx-skills compares that manifest against the Mac’s last sync and its current npx lock file, determining whether a difference was made here, or on another machine. Local additions and removals are published; which means remote changes are applied so each Mac eventually converges on the same set of skills without manually copying folders around.

At this point, I have to ask: Did I solve the problem? Absolutely. Did I engineer myself into a small pyrrhic victory along the way? Also absolutely maybe yes! My hope is that an open standard arrives tomorrow, and makes all of this gloriously obsolete. Until then, at least both Macs agree on what skills they have.

Until next time ✌️

···
// Written by // // Read it in about 5 minutes // RE: AI

This post is brought to you by Clerk. Add secure, native iOS authentication in minutes with Clerk’s pre-built SwiftUI components.

It’s hard not to have tragic copywriting as a programmer. As we stepped into this indie scene, it was (and is) all too common to ship something like this:

The Button Label Test

What will happen when you tap it?

New Event
Design review Today, 2:00–2:30 PM

The action stays the same. Only the words get clearer.

Even nearly 15 years (!!!) into this business, I can still fall into this trap. Left to my own devices, I’ll happily ship some gormless button label like “Initiate Sequence”, its copy that reads like it was written for someone who should be code reviewing a recursive function. But instead, most people who use my apps are the everyman, who isn’t serially online chatting about AI agents. Writing for them doesn’t come as naturally. Other than experience and being aware of it, is there a way to audit your interface controls for terrible copywriting?

Turns out, Apple has the answer. And, if you power it up with an agent, it can audit your entire app for you. It all starts with this banger session:

Apple Developer app showing the WWDC26 session Craft clear names for features and labels in your app.

In it, Heej walks through how to craft copy that feels human. That really helps someone understand the impact of a button press, or simply set an expectation of what will happen. This kind of thing is absolutely gold. It is the number one thing I think people like me struggle with. Here’s how I hooked up an agent to learn about that session, and then apply it to one of my apps:

  1. Install Sosumi MCP or install the WWDC.ai skill.

    For Sosumi MCP:

    claude mcp add --transport http sosumi https://sosumi.ai/mcp
    

    Or, for the WWDC.ai skill:

    npx skills add https://github.com/superwall/skills --skill wwdc --global --yes --agent claude-code universal
    
  2. Have an agent watch that session via whatever you installed on step 1.
  3. Then, point it your app and give it context on who your target market is, what your app does, and using its new copywriting knowledge - propose updates to strings throughout.

When I did this, I had fantastic results:

A code diff showing clearer share-link copy proposed across an app.

Some of them were so obvious, it hurt. But, this is just a blindspot I have. I’m not great at interface label naming, or naming in general. There’s a lot of talk about where the line should be with creativity and AI, and it’s a necessary discourse. In this case? This is one area where I’m happy to have its help.

Until next time ✌️

···
// Written by // // Read it in about 5 minutes // RE: AI

This post is brought to you by Clerk. Add secure, native iOS authentication in minutes with Clerk’s pre-built SwiftUI components.

Lately, I’ve been thinking a lot about the concept of a user interface. Like, weirdo, existential stuff. Why did we even make them in the first place?

Remember the good old days of Microsoft DOS? Just a welcoming, plain and greenish terminal. Alawys complete with a blinking green cursor inviting you to press enter, and a list of options:

Microsoft(R) MS-DOS Version 6.22 (C) Copyright Microsoft Corp 1981-1994.   C:\>NOUI   SWIFTJECTIVE-C AGENT HARNESS ----------------------------------------   Select option:

I guess the idea of a user interface is to help people get something done, perform a certain task, accomplish whatever it is they have in mind. And the easiest way to do that, is…well, visually.

Though recently, I’ve started to have a weird realization that maybe the best UI in this world of agents is sometimes… not having one at all. In fact, I dictated this post all in Codex’s mac app (yes, I know — which is a UI! Hang with me though):

A screenshot of writing a blog post in Codex for Mac.

Here are few things I would’ve made an admin dashboard, custom interface, or any interface at all for about a year ago:

  1. Customizing a server driven “Event” in a bespoke upcoming app.
  2. Reviewing feature requests for Elite Hoops.
  3. Managing blog posts for the company blog at Superwall.

Of course, in all three instances, I realized each of these things are agent driven processes. So why bother? I just tell the agent what I need. The only UI, at any stage, is the harness running the agent.

So I’m curious — is this a prominent direction the world is headed? I don’t think it’ll go so far to where we get to a place of “An ad-hoc agent and your prompt is the app for the given task”, as some people tend to think. I am, naturally, using an agent, without an extra UI in the target “thing” I’m working on, to produce some sort of output for an existing app.

Buuuuuut, it is also a little bit more in that direction than I would’ve thought. For me, a prompt can have the verisimilitude of an app: it’s a place to express intent, then I make revisions, and finally end up with the thing I wanted. Or maybe DOS had it right all along?

Until next time ✌️

···
// Written by // // Read it in about 5 minutes // RE: Siri

This post is brought to you by Clerk. Add secure, native iOS authentication in minutes with Clerk’s pre-built SwiftUI components.

The dawn of a new Siri (AI) looms in the air. When it ships, the promise of just saying what you want done, and having Siri just…do it, should be realistic. Apple, true to their roots of being masterful storytellers in terms of product, had many examples on tap. “Hey Siri, what was the water bottle I was thinking of getting a few months ago?”, and BAM — Siri went through a mountain of personal context, and found the answer.

That stuff is obvious. But, what about your app?

“Hey Siri, what team did I last make a play for in Elite Hoops?”

How do we do that, what’s involved, and how should you think about Siri and your app over the summer? I think I have a fairly good grasp on it, and I’ll summarize it here. I’m skipping deep dives, and just keeping it to a rundown.

let concatenatedThoughts = """

This is based off of having on seen this WWDC session, so I might be missing some context. Though, from what I've seen, this is where the goods are at.

"""

First off, the parts involved. The whole process is the same — it’s still app entities for your app’s models, schemas for a particular definition of those if applicable, and a few APIs to make those known to Siri when they are on screen.

App Entity

First, each of your app’s data models that should participate in Shortcuts, an App Intent, or general system-wide intelligence should hav a lightweight AppEntity version. This isn’t the same as the model itself, but a lightweight version of it:

struct TeamEntity: AppEntity {
    static var defaultQuery = TeamEntityQuery()
    static var typeDisplayRepresentation: TypeDisplayRepresentation = .init(stringLiteral: "Team")
    
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "\(name)",
                              subtitle: "\(rosterSize) \(rosterSize == 1 ? "player" : "players")",
                              image: DisplayRepresentation.Image(data: logo))
    }
    
    let id: String
    
    @Property(title: "Team Name")
    var name: String
    
    @Property(title: "Roster Size")
    var rosterSize: Int
    
    @Property(title: "Roster")
    var roster: [String]
}

Surfacing App Entities

From there, Siri needs to be able to find them. There are two routes. First, if a network trip is required or otherwise surfacing content takes more work, then you should use EntityStringQuery:

extension TeamEntityQuery: EntityStringQuery {
    func entities(matching string: String) async throws -> [TeamEntity] {
        Logs.appIntents.info("TeamEntityQuery: String query for term \(string)")
        
        guard let dao = DAO.readOnly else {
            Logs.appIntents.info("There is no database available for the intent to read from.")
            return []
        }
        
        let courts = try await dao.fetchCourtsBy(name: string)
        let entities: [TeamEntity] = try await TeamEntity.fetchData(for: courts)
        return entities
    }
}

More commonly, IndexedEntity is the best path forward. The system automatically indexes this content for you. In fact, most cases just need you to declare conformance:

extension TeamEntity: IndexedEntity {}

In fact, you can simply conform to IndexedEntity directly as opposed to AppEntity. Once you do that, you can identify which parts should be index for search, too, with @Property(indexingKey:).

This is, however, where I’m not entirely sure of the relationships between Siri and your app. If you fill type @Property(indexingKey: \.) — you’ll get a list of autocompleted things you can use here. I believe all of these tie into a predefined App Schema, and the properties within them. So, what if your app doesn’t fit into those? I’m not sure, but I don’t think you can use indexing keys in that case.

App Schemas

Speaking of App Schemas, and their associated domains, are basically juiced up App Intents. Apple has already trained Siri to the moon and back over them, it’ll understand context better, and participate in follow up questions and everything else in-between. They’ve done the work for you. So, if you app fits into one of these schemas, absolutely use it.

You do so by using a property wrapper above your entity declaration:

@AppEntity(schema: .messages.message)
struct MessageEntity: IndexedEntity {
  // The text content of the message
  @Property(indexingKey: \.textContent)
  var body: AttributedString?
}

…where messages is the overall schema, and the message is the domain within it. Xcode will also autocomplete the fields you need to implement a domain. Again, if your app’s data fits into a schema, it’s 100% what you should use. What’s less clear to me is what you can do, or should do, and how far your app can participate with Siri AI if you don’t fit into one of these predefined boxes.

On-screen Awareness

Once you’ve indexed data and exposed it to the system, next you wanna take part in that whole “Yo Siri, tell me about X or Y”, where X or Y is something currently on screen. Two different routes here:

  1. NSUserActivity: The tried and true API around, since what — iOS 8?, can help with singular content. For example, a photo.

  2. View annotations API: Use this when you’ve got more than one singular thing that’s presented, like a list of stuff.

Together, these tie back to an AppEntity, which means everything is structured in a way Siri can understand. Here’s how they look:

struct MyPractice: View {
    let practice: Practice

    var body: some View {
        VStack {
            StuffAndThings()
            PracticeContainer()
            .userActivity("com.example.elitehoops.practice", 
                          element: practice.asEntity()) { entity, activity in
              activity.title =  practice.title
              activity.appEntityIdentifier = .init(for: entity)
            }
        }
    }
}

And for view annotations:

struct MyPractices: View {
    let practices: [Practice]

    var body: some View {
        ForEach(practices) { p in 
            PracticeView(practice: p)
                .appEntityIdentifier(
                      EntityIdentifier(
                          for: PracticeEntity.self,
                          identifier: p.id
                      )
                )
        }
    }
}

Handle Data Coming In and Out

Finally, getting data out and in. When Siri wants to chain together stuff, like “Hey Siri, email this practice to Jansyn.” — we’ve now got a few moving parts. There’s the “practice”, I need to export my own data, and then there’s the recipient, Mail. To export or import, look no further than Transferable.

There is some nuance depending on the context. If you’re trying to handle an incoming request against some existing content, then you’ll need IntentValueQuery. But, if that request means your app should creating a new model or data, then implement the importing bit when wiring up your transferRepresentation within Transferable.

In Apple’s session, they use this example:

struct ContactEntityQuery: IntentValueQuery {
    func values(for input: [IntentPerson]) async throws -> [ContactEntity] {
      let names = input.map(\.displayName)
      let descriptor = FetchDescriptor<Contact>()
      let contacts = try model.mainContext.fetch(descriptor)
      let matches = contacts.filter { contact in
          names.contains(where: { name in
              contact.name.localizedStandardContains(name)
          })
      }
      return matches.map(\.entity)
    }
}

However, this is the part that feels slightly limiting. It’s typed to handle only an IntentPerson — something the system already defines. As far as I can tell, your app would have to use some value-known intent already, like IntentPerson or something from the App Schemas. Otherwise, you also wouldn’t be able to use IntentValueRepresentation — which is exactly what Siri uses for this type of stuff.

Here’s another example I found in the docs, which handles things bidirectionally:

extension TeamEntity: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        IntentValueRepresentation(
            exporting: { entity in
                IntentPerson(name: .displayName(entity.name))
            },
            importing: { person in
                ContactEntity(name: person.name.displayString)
            }
        )
        DataRepresentation(exportedContentType: .utf8PlainText) { entity in
            entity.teamSummaryText().data(using: .utf8) ?? .init()
        }
    }
}

So, I’m unsure of how to handle this beyond that. If our apps don’t fit into schema, where does that leave us? I haven’t found the answer to that.

Final Thoughts

New Siri looks fantastic. No doubt, integrating it will finally bring us to that “This is what I wanted” phase of the tech. It should be an epochal event for our little digital assistant. For us? The APIs are easy to use, and there isn’t much guesswork. My only question mark is how to best integrate when our data models don’t fit into an App Schema, and if I find concrete answers — I’ll update this post.

Until next time ✌️

···