~16 min · universal-ios, lifecycle, scenephase, sandbox, file-identity
Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"업데이트가 데이터 컨테이너를 옮겨. 절대 경로를 신원으로 쓰면 안 돼."
앱은 대부분의 시간 동안 안 돌고 있어
Mac 유틸리티는 누가 끌 때까지 돌아. 아이폰 앱은 몇 분 앞에 있다가, 백그라운드로 가고, 멈추고, 결국 시스템이 말없이 끝내. SwiftUI는 네가 손쓸 수 있는 부분을 scenePhase로 알려줘. active, inactive, background. 얇은 클라이언트한테 백그라운드는 에러 경로가 아니라 평범한 경우야. 씬이 백그라운드로 가면 초안을 저장하고, 아직 안 보낸 변경을 아웃박스로 넘기고, 소켓을 닫아. 다시 active가 되면 옛 연결이 살아남았다고 치지 말고, 새로 연결해서 기록부터 다시 재생해. 앱이 화면에 떠 있지 않을 때도 사람한테 전해져야 하는 건, 요행을 바라고 살려둔 소켓이 아니라 푸시가 맡을 몫이야.
글쓰기 편집기의 폰 앱은 이걸 그대로 따랐어. 글쓰기가 잠깐 멈추면 변경을 보내고, 앱이 앞에서 물러날 때 한 번 더 보내고, 문장 중간에 앱이 죽는 경우를 위해 복구 초안을 남겨. 백그라운드 시간을 1초 더 얻는 데 기대는 건 하나도 없어.
업데이트 뒤에 멈춘 동기화
같은 앱의 기기 동기화는 첫 TestFlight 빌드에선 잘 됐어. 두 번째 빌드가 설치되자, 모든 동기화가 아무것도 확인하기 전에 "Sync recovery belongs to another initiating replica"로 한꺼번에 멈췄어. 동기화 기록이 데이터 루트의 절대 경로를 적어두고 바이트 단위로 비교했거든. 그 뒤에는 경로에서 비롯된 문제가 두 개 더 기다리고 있었어. 루트 경로 해시를 키로 쓰는 신원 캐시는 공유 이력이 없는 새 복제본 신원을 만들었을 거야. 노트 절대 경로의 해시로 이름을 붙인 노트별 사이드카 파일은 주인 없는 파일로 보여서 삭제 대상에 올랐을 거고.
전제는 설치된 앱의 컨테이너 경로가 그대로라는 거였어. 시뮬레이터에서 재봤어. 설치된 앱 위에 다시 설치하자 Data/Application/4DE73F62…가 Data/Application/2C5B5925…로 옮겨갔고 옛 경로는 사라졌어. 그런데 안에 든 폴더와 파일의 device와 inode 번호는 그대로였어. 컨테이너가 이름만 바뀐 거야. 폰에서 본 증상과도 딱 맞았어. 여기서 나오는 규칙은 단순해. 컨테이너 위치는 켜질 때마다 FileManager로 다시 구해. 절대 컨테이너 경로를 신원으로 저장하지 말고, 저장하는 이름에 넣지도 마. 경로는 루트 기준 상대 경로로 저장해. 옛 빌드가 이미 절대 경로를 적어뒀으면 읽을 때 상대 경로로 바꿔 옮겨. "같은 파일"인지 알아야 하면 옮겨가도 살아남는 device와 inode를 비교해.
Code
떠나고 돌아오기: 얇은 클라이언트에서 씬 단계가 하는 일·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
}
}
}
}
켜질 때마다 구하는 루트, 상대 경로, 옛 기록 이전, 이동에도 살아남는 신원·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)
}
}
SparkMobile을 시뮬레이터에 설치하고, Application Support 폴더 아래에 파일을 하나 쓰고, 컨테이너 경로를 찍어. xcrun simctl install로 같은 앱을 그 위에 다시 설치하고 경로를 또 찍어. 바뀌었는지 적고, 파일의 device와 inode를 전후로 비교해. 이어서 DeviceRoot.resolve에 옛 컨테이너의 절대 경로를 넣으면 새 컨테이너 안으로 풀리는지 확인하는 테스트를 써.
Hint
xcrun simctl get_app_container <기기> <번들 id> data는 앱을 띄우지 않고 데이터 컨테이너 경로를 찍어줘. 테스트의 옛 루트와 새 루트는 임시 디렉터리에 만들고, 업데이트 때처럼 그 사이에 컨테이너 폴더 이름을 바꿔.
Progress
Progress is local-only — sign in to sync across devices.