Skip to content
C.W.K.
Stream
Lesson 01 of 06 · published

The Engine Stays Home: Apps as Thin Clients

~16 min · engine-at-home, thin-client, tailnet, architecture, endpoints

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"iOS buys OS capability, not network reach."

One Writer, Many Windows

Every family app with a phone or Mac client has the same shape. The engine runs on one Mac at home and owns the record: its database, its logs, its rules. The native apps are thin clients. They capture, display and decide, and they talk to the engine over the tailnet, the private network that already joins the family's Macs, phones and iPads. There is no public route to an engine, no Tailscale Funnel, and no second copy of the database on a device. No tailnet means no app, and that is accepted rather than engineered around.

So what does a native client add, if not reach? The family wrote the list down so nobody re-derives it per app: reliable push, a share extension, App Intents and the Action Button, declared background modes, the camera as a capture surface, storage the system will not evict, background audio, widgets and Live Activities, haptics, and a native control for anything a web button must not be able to answer. None of it changes the trust boundary or which machine writes the record.

How a Client Reaches the Engine

An engine binds 127.0.0.1 on its port and never listens on a network interface itself. Tailscale Serve publishes it to the tailnet as twins: plain HTTP on the same port number, and HTTPS on the adjacent port with a certificate for the Mac's tailnet name. On the engine's own Mac a client can use loopback; everywhere else it dials the tailnet name and one of the two twins. The next lessons cover what each twin costs a client (App Transport Security) and what it costs a Swift engine (how it binds).

Because the twins sit on adjacent ports, an address without a written port is a wrong door dialled silently. The shared endpoint type is therefore strict: a scheme of http or https, a host, a port from 1 to 65535 that the user actually typed, and nothing else but a trailing slash. A pasted path, a query or credentials are refused with a reason instead of being dropped. Every question about the address (is it loopback, which keychain account holds its PIN) is answered from the parsed host, because one app once answered "is this loopback?" from the raw text while another method used the parsed value, and a local address demanded a PIN.

The Engine Is Older Than the App

A phone updates through TestFlight on Apple's schedule; the engine updates when someone restarts it. A new build of a travel journal parked a capture with "Method Not Allowed". The request was fine. The engine at home had been running for days without the new route, and because it serves its web client from / through a static-files mount that accepts only GET and HEAD, an unknown POST fell through to it and came back 405 instead of 404. Restarting the engine fixed it. The fix that lasted was in the client: it now says that the engine is probably older than the app, names the restart command, and treats the capture as retryable rather than refused. When a client and an engine change together, expect this skew every time.

Code

A strict engine location: scheme, host, and a written port, nothing else·swift
import Foundation

struct EngineLocation: Equatable, Sendable {
    enum Failure: Error, Equatable {
        case scheme, host, portMissing, portRange, extraParts
    }

    let scheme: String   // "http" or "https"
    let host: String     // IPv6 keeps its brackets: "[::1]"
    let port: Int        // always written: the twins sit on adjacent ports

    var baseURL: URL { URL(string: "\(scheme)://\(host):\(port)")! }

    /// Strict: a scheme, a host and a WRITTEN port, nothing else but a trailing "/".
    init(parsing text: String) throws(Failure) {
        let trimmed = text.trimmingCharacters(in: .whitespaces)
        guard let components = URLComponents(string: trimmed),
              let scheme = components.scheme?.lowercased(), scheme == "http" || scheme == "https"
        else { throw .scheme }
        guard components.user == nil, components.password == nil,
              components.query == nil, components.fragment == nil,
              components.path.isEmpty || components.path == "/"
        else { throw .extraParts }
        guard let rawHost = components.percentEncodedHost, !rawHost.isEmpty else { throw .host }
        guard let port = components.port else { throw .portMissing }
        guard (1...65_535).contains(port) else { throw .portRange }
        self.scheme = scheme
        self.host = rawHost.contains(":") && !rawHost.hasPrefix("[") ? "[\(rawHost)]" : rawHost
        self.port = port
    }

    /// Answered from the parsed host, never the typed string. "127.evil.com" is a name, not loopback.
    var isLoopback: Bool {
        let bare = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]")).lowercased()
        let octets = bare.split(separator: ".", omittingEmptySubsequences: false)
        let ipv4Loopback = octets.count == 4 && octets.allSatisfy { UInt8($0) != nil } && octets[0] == "127"
        return bare == "localhost" || bare == "::1" || ipv4Loopback
    }
}

// "http://office.example.ts.net:8900"  -> ok
// "http://office.example.ts.net"       -> portMissing
// "http://127.0.0.1:8900/api"          -> extraParts
// "HTTP://[::1]:8900"                  -> ok, isLoopback == true

External links

Exercise

Add EngineLocation to SparkKit with a self-test that pins the four commented cases plus http://user@host:1, ftp://host:21, http://host:0 and https://host:8901/. Give Spark's settings screen a single text field for the engine location that shows the failure reason under the field. Then decide, in one sentence in the README, what Spark's client should do with a 405 from its engine, and implement that message.
Hint
URLComponents.port is nil when no port was written, which is exactly the case to refuse. Keep the failure reasons as enum cases and turn them into sentences in the view, so the self-test never compares prose.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.