İçeriğe geç
codebiy
← Yazı masasına dön

iOS'te UserDefaults boş dönüyorsa bu ilk açılış demek değildir

Kilit ilk kez açılmadan önce UserDefaults okunamaz, hata da vermez. Düzeltmelerimizden biri bunu yeni kurulum sayıp kullanıcının API anahtarını silebilirdi. Hata, hiçbir telefonda çalışmadan incelemede yakalandı.

Tillroll, geliştiricinin günlük App Store satış raporlarını, yine onun kendi App Store Connect API anahtarıyla cihazda okuyan iPhone ve iPad uygulamamız. Bu anahtarın özel kısmı Apple'dan yalnızca bir kez indirilebiliyor. Yani uygulamanın asla yapmaması gereken tek şey, onu kaybetmek. 7 Ekim 2026'da düzeltmelerimizden birinde temizlik kodu, açılışın daha geç bir noktasına taşındı. Kod, taşındığı yerde anahtarı silebilirdi.

Temizlik kodu, önceki kurulumdan kalan Keychain kaydını siliyordu ve ilk açılışı UserDefaults'taki bir flag'den anlıyordu. Oysa iPhone yeniden başladıktan sonra kilit ilk kez açılana kadar UserDefaults okunamaz, hata da vermez: okunamayan flag de hiç yazılmamış flag gibi false döner. Kod taşındıktan sonra, o durumda başlamış bir süreç temizliğe daha geç, anahtarın okunabildiği ama flag'in hâlâ false okunduğu bir anda ulaşabilirdi.

İllüstrasyon: iki çekmeceli alçak dolapta soldaki çekmece açık ve boş, sağdakine kırmızı asma kilit vurulmuş, yuvarlak camından içindeki anahtar görünüyor. Henüz okunamayan değer, hiç yazılmamış değer demek değildir.

Tillroll'un kodunu, bizim yönlendirdiğimiz yapay zekâ agent'ları yazıyor. Düzeltmeyi bir agent yazdı. Onu incelemesi istenen ikinci agent, taşınan temizliği “kritik, şüpheli” diye işaretledi. Temizlik, taşındıktan yaklaşık 45 dakika sonra kaldırıldı. Silmenin gerçekleştiğini hiç görmedik: uygulama o güne kadar yalnızca simülatörlerimizde çalışmıştı, simülatör de o duruma sokulamıyor. Uygulama henüz App Store'da da değildi. Bu yüzden hiçbir anahtar kaybolmadı.

Uygulamayı Swift 6 ile, iOS 26 ve sonrası için yazıyoruz; Xcode 27.0 ile derliyor, iOS 27.0 simülatöründe çalıştırıyoruz.

Kilit ilk kez açılmadan önce iOS'in döndürdükleri

Yeniden başlayan iPhone, kilidi bir kez açılana kadar saklanan verinin çoğunu erişime kapalı tutar. Burada üç saklama yeri önemli ve her biri başka türlü yanıt veriyor:

  • Dosyalar. Başka bir sınıf seçmediyseniz dosya completeUntilFirstUserAuthentication sınıfını alır. Apple'ın dosya şifrelemeyi anlatan sayfasına göre böyle bir dosyaya, kullanıcı cihazın kilidini ilk kez açana kadar erişilemez. Okuma hata fırlatır.
  • Keychain. kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ile saklanan kayıt da okunamaz. SecItemCopyMatching, errSecInteractionNotAllowed (-25308) hatası verir.
  • UserDefaults. Arkasındaki dosya aynı korumalı container'da durur, ama API'nin döndürebileceği hata yok. Apple'ın bir destek mühendisi bunu 2015 ve 2016'da Apple Developer Forums'ta anlattı: hiç yazılmamış bir değeri, o an getirilemeyen bir değerden ayırt etmenin yolu yok. Bu yüzden bool(forKey:) ikisi için de false döndürür.

Uygulama kodunun o durumda çalışıp çalışmadığı ise o kadar net değil. Apple'ın uygulama açılışını anlatan dokümantasyonuna göre sistem, cihaz yeniden başladıktan sonra uygulamayı önceden hazırlayabilir (prewarming). Ama aynı dokümantasyona göre uygulamanın süreci, kodun tek satırı bile çalışmadan askıya alınır. Aynı mühendis 2025'te şunu yazdı: iOS, kilit ilk kez açılmadan önce üçüncü taraf kodu çalıştırmaktan genellikle kaçınır, ama bunun olabildiği durumlar vardır. Biz bunu mümkün sayıyoruz.

Telefonun sıradan kilitli hâli ise başka bir durum: kilit bir kez açıldıktan sonra bu sınıflardaki dosyalar ve Keychain kayıtları telefon kilitliyken de okunabilir. Arka plan yenilemesi de push da o sırada gelir ve ikisine de anahtar gerekir. Anahtarı bu sınıfta saklamamızın sebebi bu. Apple'ın satış raporları endpoint'inin bize ne döndürdüğünü ayrı bir yazıda anlattık.

İlk açılış temizliğinin anahtarı silebileceği senaryo

Temizliğin bir gerekçesi vardı. Keychain kaydı, uygulama silindikten sonra da yerinde kalır. Yeni kurulum da eski kurulumun anahtarını bulup bağlıymış gibi görünürdü.

İlk sürüm bu yüzden uygulama modelinin initializer'ında bir kontrol yapıyordu. UserDefaults'taki launched flag'i false okunuyorsa ve Keychain'den anahtar okunabiliyorsa anahtarı siliyor, flag'i de yazıyordu. Kilit ilk kez açılmadan önce bu güvenliydi, ama kıl payı: flag false okunuyordu, Keychain okuması da başarısız oluyordu ve temizlik yalnızca okuyabildiği anahtarı siliyordu.

İlk incelemeyi yapan agent orada başka bir hata buldu: initializer, kullanıcının kayıtlı seçimlerini de UserDefaults'tan okuyordu. Kilit ilk kez açılmadan önce başlayan uygulama, süreç sonlanana kadar bağlı kullanıcıya karşılama ekranını gösterecekti. Düzeltmede bu okumaları, flag'in true okunduğu ya da isProtectedDataAvailable'a göre telefonun kilidinin açık olduğu ilk yenilemeye erteledik. Temizlik de onlarla birlikte taşındı:

private func restore() {
    let defaults = UserDefaults.standard
    guard !restored, defaults.bool(forKey: Self.launchedKey)
            || UIApplication.shared.isProtectedDataAvailable else { return }
    Self.forgetEarlierInstall(defaults)
    // … then the stored source and currency are read
}

private static func forgetEarlierInstall(_ defaults: UserDefaults) {
    let earlier = try? KeychainStore.load()
    guard !defaults.bool(forKey: launchedKey) else { return }
    if earlier != nil { try? KeychainStore.delete() }
    defaults.set(true, forKey: launchedKey)
}

İkinci incelemede bu kod şu sırayla izlendi:

  1. Telefon yeniden başlar ve iOS, kimse kilidi açmadan Tillroll'un sürecini başlatır.
  2. Initializer flag'i okur. UserDefaults henüz okunamadığı için flag false gelir ve hiçbir şeye karar verilmez.
  3. Kullanıcı telefonun kilidini açar, uygulamayı açar. Süreç hâlâ aynı süreçtir.
  4. İlk yenileme restore() fonksiyonunu çağırır. isProtectedDataAvailable true olduğu için, flag ne okunursa okunsun guard geçilir.
  5. Temizlik, anahtarı okur ve bu kez okuma başarılı olur. Sonra flag'i okur. UserDefaults hâlâ 2. adımdaki gibi yanıt veriyorsa bu, ilk açılış sayılır ve anahtar silinir.

Initializer'da okunamayan flag hep okunamayan anahtarla birlikte geliyordu. Taşınınca temizlik, anahtar okunabilirken de çalışabilir oldu ve her şey flag'e kaldı.

Beşinci adım, netleştiremediğimiz bir noktaya bağlı: UserDefaults'u kilitliyken okumuş bir süreçte, kilit açıldıktan sonra UserDefaults dönüp dosyasını yeniden okuyor mu? Apple'ın dokümantasyonunda bu yazmıyor. 2015'teki forum başlığında geliştiriciler, uygulama yeniden başlatılana kadar boş kaldığını anlatıyor. Apple'ın mühendisi bunu doğrulamıyor.

Bulgu bu yüzden incelemede “kritik, şüpheli” diye işaretlendi. İşi koordine eden agent da yanıt ne çıkarsa çıksın sorun yaratmayacak çözümü seçti. Silinen anahtar bir daha getirilemez: indirdiği dosya elinde değilse kullanıcının App Store Connect'te yeni bir anahtar oluşturması gerekir.

Ardından gelen düzeltmede temizliği kaldırdık. Bunun bedeli gizlilik politikasına yansıdı: politikada, kullanıcılardan uygulamayı silmeden önce bağlantılarını kaldırmaları isteniyor. Yeniden kurulan uygulama artık karşılama ekranında açılıyor ve geride kalan anahtarı yok sayıyor. Kullanıcı bağlandığında o anahtarın yerine yenisi yazılıyor ya da anahtar, kaldırılabilen bir bağlantı olarak görünüyor.

Kural: boş okumayla hiçbir şeye karar verilmez

Kural, her agent oturumunun işe başlamadan önce okuduğu dersler dosyasına girdi:

Bir okuma boş döndü diye hiçbir şey silinmez, sıfırlanmaz, hiçbir şeyin üzerine yazılmaz. Kullanıcının ne gördüğünü ya da neyi elinde tuttuğunu belirleyen durum, okunamadığında hata veren bir dosyada durur. Bir düzeltme kodun ne zaman çalıştığını değiştiriyorsa, o kodun taşındığı anda ne görebildiğine yeniden bakılır.

“Bu yeni bir kurulum mu?” sorusunu artık UserDefaults'a sormuyoruz. Seçilen veri kaynağı (yok, örnek ya da bağlı) küçük bir dosyada duruyor. Değer sır olmadığı için dosyayı .noFileProtection ile yazıyoruz ve dosya her kilit durumunda okunabiliyor.

Dosya yoksa bu, kurulumun ilk açılışıdır. Dosya var ama okunamıyorsa durum “henüz bilinmiyor” sayılır: uygulama karşılama ekranını gösterir, hiçbir şey yazmaz ve sonraki yenilemede yeniden okur. 2015'teki başlıkta verilen tavsiye de buydu: arka planda çalışan kodun güvendiği veriyi, korumasını sizin belirlediğiniz bir dosyada tutun.

Para birimi ile dönem hâlâ UserDefaults'ta duruyor. Oradan okunana yalnızca iki durumda güveniyoruz. Ya launched flag'i true okunuyordur ki okunamayan UserDefaults bunu asla döndüremez. Ya da bu, telefonun kilidi açıkken yaşanan bir ilk açılıştır, yani saklanan şey gerçekten boştur.

İkinci durumda isProtectedDataAvailable'a bakıyoruz. Aynı mühendis böyle bir ön kontrolün özünde yarış durumuna açık olduğunu yazıyor. Bizde bu kontrol yalnızca boş okunan bir değere inanıp inanmayacağımızı belirliyor, bir şeyin silinip silinmeyeceğini değil.

Keychain wrapper'ımız da kurala uyuyor: yalnızca errSecItemNotFound için nil döndürüyor, başka her durum kodunu hata olarak fırlatıyor. Bunun neyi önlediği, mühendisin sonraki forum yazısında anlatılıyor: bir kimlik bilgisini okuyan, okuma başarısız olunca da kaydı silip her şeye sıfırdan başlayan kodu.

Dosyalar: yok, bozuk ve okunamıyor ayrı durumlardır

Uygulama bir bağlantı listesi tutuyor: her API anahtarı için bir bağlantı, yanında kullanıcının verdiği ad ve bağlantının duraklatılıp duraklatılmadığı. Optional, üç durumu da nil'e indirirdi. Bu yüzden dosyayı okuyan fonksiyon enum döndürüyor:

private enum StoredList {
    case book(ConnectionBook)
    /// No file: no list yet. A fresh install, or the data of a build that kept none.
    case none
    /// The file opens and is no list.
    case damaged
    /// The file is there and cannot be read, as before the first unlock after a restart.
    case unreadable(any Error)
}

private nonisolated static func storedList() -> StoredList {
    #if DEBUG
    if DebugLaunch.listLocked { return .unreadable(CocoaError(.fileReadNoPermission)) }
    #endif
    do {
        let list = try Data(contentsOf: listFile)
        return (try? JSONDecoder().decode(ConnectionBook.self, from: list))
            .map(StoredList.book) ?? .damaged
    } catch let error as CocoaError where error.code == .fileReadNoSuchFile {
        return .none
    } catch {
        return .unreadable(error)
    }
}

Uygulama yalnızca hiç bağlanmamış bir kurulumdaki .none durumunda boş görünebilir. Bozuk bir liste ya da kurulum bağlı olduğu hâlde bulunamayan bir liste, Keychain'deki anahtarlardan ve cihazdaki rapor klasörlerinden yeniden kuruluyor. Yeniden kurulan bağlantıların hiçbiri duraklatılmıyor, çünkü listenin kaybolması, kullanıcının parasını ödediği plan hakkında hiçbir şey göstermez. Okunamayan liste yüzünden hiçbir şey duraklatılmaz, kaldırılmaz ya da yazılmaz.

Widget'ın dosyası da aynı muameleyi görüyor. Dosya var ama okunamıyorsa beş dakika sonra yeniden isteniyor. Açılan ama çözümlenemeyen dosya ise yeniden denenmiyor: onu bir daha okumak bir şeyi değiştirmez.

Test edebildiklerimiz ve edemediklerimiz

Bir telefonda, iOS'in uygulamayı kilit ilk kez açılmadan önce başlatmasını sağlayacak adımlar elimizde yok. Hatayı yeniden üretemediğimiz için şunları yaptık:

  • Okuyarak. Üçüncü incelemede, düzeltmenin kilitli telefonla ilgili kod yolları okunarak kontrol edildi.
  • Simülatörde. Yeni kurulum karşılama ekranında açılıyor, bağlantı da uygulama yeniden başlatılınca yerinde kalıyor. Örnekteki DEBUG dalı kilitli dosyanın yerini tutuyor: o başlatma argümanıyla ve planın izin verdiğinden fazla bağlantıyla çalıştırdığımızda uygulama hiçbir şeyi duraklatmadı, hiçbir şey yazmadı.
  • Bir iPhone'da. 7 Ekim 2026'da, TestFlight build'i yüklü ve kilidi bir kez açılmış telefon kilitliyken bildirim extension'ı, anahtarı okudu ve en yeni raporu indirdi. Telefonun iOS sürümünü not etmedik.

İmzasız simülatör build'i burada işe yaramıyor: Keychain çağrıları errSecMissingEntitlement (-34018) hatası verdi, biz de CODE_SIGN_IDENTITY=- ile ad hoc imzalıyoruz. Hâlâ açık olan iki soru var: UserDefaults, onu kilitliyken okumuş bir süreçte kendine geliyor mu ve iOS, Tillroll'u kilit ilk kez açılmadan önce ne sıklıkla başlatıyor? Artık iki yanıt da bir şeyin silinmesine ya da üzerine yazılmasına yol açamıyor: en kötü durumda böyle bir süreç, yeniden başlatılana kadar varsayılan para birimini ve dönemi gösteriyor.

Kendi uygulamanızda kontrol edilecekler

  • Açılışta yapılan her okumanın, kilit ilk kez açılmadan önce ne döndürdüğünü bilin: hata mı, yoksa geçerli görünen bir değer mi.
  • “Yeni kurulum” kararını UserDefaults'a bakarak vermeyin. .noFileProtection ile yazılmış bir dosyanın var olup olmadığına bakın.
  • Bir okuma başarısız oldu ya da boş döndü diye hiçbir şeyi silmeyin. Keychain kodunda yalnızca errSecItemNotFound için nil döndürün.
  • “Yok”, “bozuk” ve “okunamıyor” durumlarını tipte ayrı ayrı tanımlayın ki kod onları karıştıramasın.
  • Bir düzeltme kodun ne zaman çalıştığını değiştiriyorsa, o kodun taşındığı anda ne görebildiğine yeniden bakın.
OKUMAYA DEVAM ETApp Store Connect salesReports, rapor saati gelmeden “satış yok” dedi ↗Expo OTA güncellemesi eski build'leri çökertti: lazy import yetmedi ↗