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

The iOS Lifecycle: Backgrounding Is Normal, and the Container Moves

~16 min · universal-ios, lifecycle, scenephase, sandbox, file-identity

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"An update moves the data container. Nothing may treat an absolute container path as identity."

The App Is Not Running Most of the Time

A Mac utility runs until someone quits it. An iPhone app is in the foreground for a few minutes, then backgrounded, suspended, and eventually terminated by the system without a word. SwiftUI reports the part you can act on through scenePhase: active, inactive and background. For a thin client, backgrounding is the ordinary case rather than an error path. When the scene goes to the background, save the draft, hand resting changes to the outbox and close the socket. When it becomes active again, reopen and replay from the record instead of assuming the old connection survived. Anything that must reach the person while no screen is attached belongs to push, not to a socket kept alive in hope.

The prose editor's phone app took this literally: it sends its changes once writing rests and again when the app leaves the foreground, and it keeps a recovery draft for the case where the app is killed mid-sentence. Nothing depends on getting one more second of background time.

The Sync That Stopped After an Update

The same app's device sync worked on the first TestFlight build. After the second build installed, every sync stopped at once with "Sync recovery belongs to another initiating replica", before checking anything. The sync journal had recorded the data root's absolute path and compared it byte for byte. Two more path-derived things were waiting behind it: an identity cache keyed by a hash of the root path, which would have minted a new replica identity with no shared history, and per-note sidecar files named by a hash of each note's absolute path, which would have looked orphaned and been planned as deletions.

The assumption was that an installed app's container path stays put. Measured in the Simulator, reinstalling over the installed app moved Data/Application/4DE73F62… to Data/Application/2C5B5925…, the old path no longer existed, and the device and inode numbers of a folder and a file inside were unchanged: the container had been renamed, and the phone's symptom matched. The rules that follow are simple. Re-derive container locations on every launch through FileManager. Never persist an absolute container path as identity, and never feed one into a persisted name. Store paths relative to the root. Where an older build already wrote absolute paths, rebase them on read. And when you need to know that a file is "the same file", compare device and inode, which survive the move.

Code

Leave and return: what the scene phase is for in a thin client·swift
import SwiftUI

@MainActor
@Observable
final class EngineSession {
    private(set) var isConnected = false
    func leaveForeground() {
        // Short and synchronous in spirit: save the draft, hand resting changes to the outbox,
        // close the socket. Backgrounding is the normal case for a remote client, not an error.
        isConnected = false
    }
    func returnToForeground() {
        // Reopen and replay from the record; never assume the socket survived.
        isConnected = true
    }
}

@main
struct SparkMobileApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @State private var session = EngineSession()

    var body: some Scene {
        WindowGroup {
            Text(session.isConnected ? "Connected" : "Offline")
        }
        .onChange(of: scenePhase) { _, phase in
            switch phase {
            case .background: session.leaveForeground()
            case .active: session.returnToForeground()
            default: break
            }
        }
    }
}
A root re-derived each launch, relative paths, a rebase for old records, and an identity that survives the move·swift
import Foundation

/// Where the app's data lives on THIS launch. Re-derived every time, never stored:
/// an app update can move the whole container to a new path.
enum DeviceRoot {
    static func current(fileManager: FileManager = .default) throws -> URL {
        try fileManager.url(for: .applicationSupportDirectory, in: .userDomainMask,
                            appropriateFor: nil, create: true)
            .appending(path: "Spark", directoryHint: .isDirectory)
    }

    /// Records store paths RELATIVE to the root. A record written by an older build that
    /// stored an absolute path is rebased onto today's root by the part after the root folder.
    /// The FIRST occurrence is the root: a folder below it may carry the same name.
    static func resolve(_ stored: String, root: URL) -> URL {
        guard stored.hasPrefix("/") else { return root.appending(path: stored) }
        let marker = "/" + root.lastPathComponent + "/"
        guard let range = stored.range(of: marker) else { return URL(filePath: stored) }
        return root.appending(path: String(stored[range.upperBound...]))
    }
}

/// Device and inode survive the container's move; the path does not.
struct FileIdentity: Equatable, Sendable {
    let device: UInt64
    let inode: UInt64

    init?(url: URL) {
        var info = stat()
        guard lstat(url.path, &info) == 0 else { return nil }
        device = UInt64(UInt32(bitPattern: info.st_dev))
        inode = UInt64(info.st_ino)
    }
}

External links

Exercise

Install SparkMobile on a simulator, write a file under its Application Support folder, and print the container path. Install the same app again over itself with xcrun simctl install and print the path again; record whether it changed and compare the file's device and inode before and after. Then write a test for DeviceRoot.resolve that feeds it an absolute path from the old container and asserts it resolves inside the new one.
Hint
xcrun simctl get_app_container <device> <bundle id> data prints the data container path without launching the app. Build the test's old and new roots in a temporary directory and rename the container folder between them, the way the update does.

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.