Skip to content
C.W.K.
Stream
Lesson 02 of 05 · published

The Apple Child

~15 min · swift, mapkit, localization, search, maps

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The canonical child is a thin translator

AppleMapsProvider is the first and canonical child of RouteProvider, and Dad picked it for plain reasons: no key, no cost, and his own driving found Apple's routing in Korea about as good as the domestic maps. It lives in its own package target, PathfinderAppleMaps, which is the only place in the package that imports MapKit and Core Location. Its whole job is translation. MapKit types come in; Place and NavRoute go out. The child pays the translation cost, so nothing downstream ever sees an MKRoute.

Routes: one request, the drive's own options

A route request is an MKDirections.Request built from the trip's RouteOptions:

  • transportType comes from TravelMode: .walking for a walk, .automobile otherwise.
  • requestsAlternateRoutes follows options.alternatives. The preview asks for alternatives; a reroute asks for one.
  • Toll and highway preferences are set only when driving. A footpath has no toll to avoid.
  • departureDate is now, so traffic counts.

A thrown error becomes RouteProviderError.unavailable, an empty answer becomes .noRoute, and every route that comes back is stamped with the mode it was asked in. That stamp matters more than it looks: lesson five shows a walk that once came back from a reroute as a driving route. travelTime builds the same request and calls calculateETA(), which is all the mid-drive ETA refresh needs.

One parameter goes unused. The root's routes takes a heading "so a reroute does not begin with a U-turn the car cannot make", but an MKDirections.Request has nowhere to put one. The architecture lists it as a known limit instead of faking it. A child is allowed to be honest about what its SDK cannot do.

Asking Apple for Korean

MapKit writes route names, map labels and step instructions in the app process's language, not the bundle's. Measured on an English simulator, Apple answered "Jayu-ro" and "Turn left onto Jeongbalsan-ro". With Korean asked for, the same request gave "자유로" and "백마로(으)로 우회전". Pippa's voice and the chunker that splits lines into cached pieces are Korean, so English step words would be read a word at a time in the wrong language.

The first fix was PathfinderApp.preferKorean, which put ko-KR first in the app's AppleLanguages default. The code has moved on since: commit a1729c1 replaced it with AppLanguage.apply, called from the app's init, plus a map-language setting. Korean comes first by default. With the local option, the country's own language goes first and Korean second, so the map speaks the country's language while Pathfinder's own words still resolve to Korean (the bundle localizes Korean only). Either way it takes effect from the next launch, because iOS reads AppleLanguages when the process starts. The architecture doc and the repo's house file still say preferKorean; the code says AppLanguage, and the code is what runs.

One more Korean wrinkle: Apple writes "(으)로" when it will not pick the particle for a road name. The screen resolves it the same way the voice does, through InstructionChunker.readable.

When MapKit said "not found" in Hangul

On Dad's phone, on-device MKLocalSearch answered every Hangul query with MKErrorDomain 4 (GEOError -8): 백석역, 온봄의원, 한국외국어대학교, all with a correct centre, while English queries worked. The simulator did the same. Apple's Maps Server API, asked from cwkPippa, found every one. So the app grew a door: GET /api/location/search?q=&lat=&lon= on cwkPippa, which runs the Server API search for the phone (commit 371baa9).

The door is a first try, never a dependency. It is used only when the selected stack is Apple, Dad's PIN is on the phone, and nothing is being simulated. An empty or failed answer falls straight through to on-device MapKit. Its places come back tagged apple, because they are Apple's data either way. It is not magic, either: it still misses abbreviations ("한국외대" found nothing, and neither did "hufs" nearby).

Inside the child, MapKit's own "no results" is not treated as a failure. MKError.placemarkNotFound, which is MKErrorDomain 4, is caught and returned as an empty list. "Nothing matched" is an answer, and it must not look like an outage that would hide the next fallback.

A hint, then a fence

Todo #300 found the other half of bad search: results that were real places, just absurdly far away. A search sends a 30 km region around Dad as a hint, which keeps a far name far (부산역 and 에버랜드 should still answer). But a generic word got read as a far place: "hospital" by Ilsan answered one 9,614 km away, "병원" a village 154 km off, "편의점" by Kyoto Station a shop in Laos.

So when nothing in the hint's answer lies within NearbySearch.reach (100 km), the child asks the same region again with regionPriority = .required (iOS 18 and later): a fence. The two answers merge in NearbySearch.merge. A far place in Dad's own country keeps the top (해운대 really is Haeundae), the nearby places follow nearest first, and a far place in another country goes last. Dad's country is the one most of the nearby places share. Without a fence, a last guard drops anything beyond 300 km whenever something closer exists ("hufs" around Ilsan once answered Hays, Texas). cwkPippa's door runs the same rule in Python, apple_maps.search_near, with the same 100 km reach.

A child is judged by its answers, not its SDK's promises. Apple Korean routing, Hangul search, a search near Dad: each was measured on real requests. Some fixes sit inside the child: "no results" becomes an empty list, and a hint that misses becomes a fence. Two do not. The language is the whole process's, so AppLanguage.apply runs in the app's init. And the Apple child is built with nothing (AppleMapsProvider()), so it holds neither the link to cwkPippa nor Dad's PIN; that is why AppModel asks selected.id == "apple" and tries the door before the child. It is the one search branch on a provider's name, and it stands in the code in plain sight. A leak you can point at is cheaper than one you pretend away.

Code

PathfinderAppleMaps, AppleMapsProvider.swift: routes(from:heading:to:options:) (verbatim excerpt)·swift
    public func routes(from origin: Coordinate, heading: Double?, to destination: Place, options: RouteOptions)
      async throws -> [NavRoute]
    {
      let request = MKDirections.Request()
      request.source = Self.mapItem(origin, name: nil)
      request.destination = Self.mapItem(destination.coordinate, name: destination.name)
      request.transportType = Self.transport(options.mode)
      request.requestsAlternateRoutes = options.alternatives
      if options.mode == .driving {
        request.tollPreference = options.avoidTolls ? .avoid : .any
        request.highwayPreference = options.avoidHighways ? .avoid : .any
      }
      request.departureDate = Date()
      let response: MKDirections.Response
      do {
        response = try await MKDirections(request: request).calculate()
      } catch {
        throw RouteProviderError.unavailable(String(describing: error))
      }
      guard !response.routes.isEmpty else { throw RouteProviderError.noRoute }
      return response.routes.map {
        var route = Self.navRoute(from: $0, origin: origin, destination: destination)
        route.mode = options.mode
        return route
      }
    }
PathfinderAppleMaps, AppleMapsProvider.swift: search with a hint, then a fence (verbatim excerpt)·swift
    public func search(_ query: String, near: Coordinate?, limit: Int) async throws -> [Place] {
      let request = MKLocalSearch.Request()
      request.naturalLanguageQuery = query
      request.resultTypes = [.pointOfInterest, .address]
      guard let near else { return Array(try await Self.found(request).map(\.place).prefix(max(1, limit))) }
      request.region = MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: near.lat, longitude: near.lon),
        latitudinalMeters: searchRadius, longitudinalMeters: searchRadius)
      let hint = try await Self.found(request)
      var places = hint.map(\.place)
      places.sort { Geo.distance(near, $0.coordinate) < Geo.distance(near, $1.coordinate) }
      if !NearbySearch.reaches(places, near: near), #available(iOS 18.0, macOS 15.0, *) {
        // Nothing within reach: the region again, as a fence ("hospital" by
        // Ilsan answered one 9,614 km off on this path; see `NearbySearch`).
        request.regionPriority = .required
        let fence = try await Self.found(request)
        if !fence.isEmpty {
          let countries = Dictionary((hint + fence).map { ($0.place.id, $0.country) }) { first, _ in first }
          places = NearbySearch.merge(hint: places, fence: fence.map(\.place), near: near) { countries[$0.id] ?? nil }
          return Array(places.prefix(max(1, limit)))
        }
      }
      places = Self.nearbyFirst(places, near: near)
      return Array(places.prefix(max(1, limit)))
    }
PathfinderMobile, AppModel.swift: Pippa's Apple Maps door first, MapKit behind it (verbatim excerpt)·swift
    let selected = provider
    let center = searchCenter
    let useAppleDoor = !isSimulating && selected.id == "apple" && hasPIN
    let link = pippa
    let task = Task {
      if useAppleDoor, let found = try? await link.search(text, near: center), !found.isEmpty {
        return found
      }
      return try await selected.search(text, near: center, limit: 15)
    }

External links

Exercise

In a small macOS command-line tool or an Xcode playground, run an MKLocalSearch for a generic word ("pharmacy", "hospital") around a city you know, first with a region as a plain hint and then with regionPriority set to .required. Print each result's name and its distance from the centre. Then run the same query once with the process language set to another language (for a command-line tool, pass -AppleLanguages '(xx)' as an argument) and compare the names that come back.
Hint
Compute distance with CLLocation(latitude:longitude:).distance(from:). regionPriority needs macOS 15 or iOS 18. If a query throws MKError.placemarkNotFound, catch it and print "no results" rather than letting the tool crash: that is the same rule the Apple child follows.

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 in — Please sign in to comment.

No comments yet — be the first.