Если вы разрабатываете iOS-приложение с виджетом, Watch-компаньоном (это приложение для Apple Watch, которое работает в паре с основным приложением на iPhone) или Share Extension - рано или поздно вам придётся передавать данные между процессами. App Groups - стандартный механизм для этого, и на первый взгляд он прост: добавил capability, написал UserDefaults(suiteName:), поехали. Но именно эта кажущаяся простота и создаёт проблемы. Данные лежат в незашифрованном контейнере, любое приложение из вашей команды может их прочитать, а валидация входящих данных почти никогда не делается. Давайте же рассмотрим, как правильно настроить App Groups, что реально можно туда класть, какие риски существуют и как организовать безопасный обмен (в том числе с примером передачи токена авторизации между приложением и виджетом).

Когда без App Groups не обойтись

Sandbox в iOS построен жёстко: каждое приложение живёт в своём контейнере, и по умолчанию никакой доступ к файлам соседних процессов невозможен. Это хорошо с точки зрения безопасности, но создаёт очевидную проблему, как только у вас появляется экосистема из нескольких таргетов.

Типичные сценарии: основное приложение и виджет WidgetKit должны показывать одни и те же данные; Share Extension должна сохранять принятый контент, чтобы основной процесс его обработал при следующем запуске; Watch-приложение хочет получить кешированные данные без обращения к сети. Во всех этих случаях App Groups - единственный нативный способ организовать общий контейнер без серверного посредника.

Важно понимать, что App Groups - это не про межпроцессное взаимодействие в реальном времени. Здесь нет сокетов, нет нотификаций с гарантированной доставкой (ну, почти - CFNotificationCenter может кое-что, но это отдельная история). App Groups - это про общее хранилище. Если вам нужна синхронизация в реальном времени, смотрите в сторону XPC или Darwin Notifications поверх общего хранилища.

Как настроить и где всё ломается

Теоретически настройка App Groups занимает минуту. На практике я видел кучу проектов, где это превращалось в полдня дебага из-за несоответствия provisioning profiles.

Начинается всё в Xcode: Signing & Capabilities+ CapabilityApp Groups. Добавляете идентификатор в формате group.com.yourcompany.appname. Важно: этот же идентификатор нужно добавить в каждый таргет, который будет участвовать в обмене данными - основное приложение, виджет, extension.

Xcode автоматически обновляет .entitlements-файл таргета:

<!-- MyApp.entitlements -->
<key>com.apple.security.application-groups</key>
<array>    
  <string>group.com.yourcompany.myapp</string>
</array>

Казалось бы, достаточно. Но нет - Xcode также должен обновить provisioning profile для каждого таргета через Apple Developer Portal. Если у вас ручное управление подписями, нужно зайти в Certificates, Identifiers & Profiles, найти App ID для каждого таргета и убедиться, что там включена capability App Groups с нужным идентификатором. Затем пересоздать профили.

Одна из самых частых ошибок - App Group зарегистрирован только для основного App ID, но не для Extension. Поведение при этом коварное: на симуляторе всё работает (там проверки профилей мягче), а на устройстве данные просто не читаются без каких-либо ошибок в логах.

Если у вас несколько extension-таргетов с разными bundle ID - убедитесь, что все они добавлены в группу. Это приходится делать вручную в портале, и Xcode вас об этом не предупредит.

Что можно шарить, а что - нет

Общий контейнер App Group предоставляет URL вида /private/var/mobile/Containers/Shared/AppGroup/<UUID>/. Туда можно писать что угодно: файлы, SQLite-базы, Core Data stores. UserDefaults с suite name - это просто обёртка над plist-файлом в том же контейнере.

UserDefaults(suiteName: "group.com.yourcompany.myapp") - самый простой способ шарить небольшие данные. Он хорошо подходит для флагов, простых настроек, timestamp последнего обновления. Я стараюсь не класть туда ничего объёмнее пары сотен байт - не потому что есть жёсткое ограничение (его нет), а потому что UserDefaults грузит весь plist в память целиком.

Для объёмных данных лучше работать напрямую с файлами. URL контейнера получается через:

let containerURL = FileManager.default    
  .containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourcompany.myapp")

Core Data поверх App Group работает, но требует аккуратности. Store нужно создавать с URL внутри контейнера, и если к нему могут одновременно обращаться несколько процессов (например, extension пишет, пока основное приложение читает), нужно использовать WAL journal mode и быть готовым к тому, что NSPersistentContainer не обрабатывает конкурентный доступ между процессами автоматически. На практике я предпочитаю для обмена между процессами использовать файлы с атомарной записью, а Core Data держать только в основном процессе.

Что точно не стоит делать - хранить в shared container большие бинарные данные, которые часто перезаписываются. Каждый процесс держит файл открытым по-своему, и при записи легко получить race condition. Для безопасной атомарной записи нужно писать во временный файл, а потом делать FileManager.replaceItem(at:withItemAt:).

Риски безопасности, о которых не принято говорить

Shared container не шифруется дополнительно сверх стандартного шифрования файловой системы iOS. Данные защищены классом NSFileProtectionCompleteUntilFirstUserAuthentication по умолчанию - то есть доступны после первой разблокировки устройства, что в реальности означает «почти всегда».

Главный риск, который часто недооценивают: любое приложение, подписанное с тем же Team ID и зарегистрированное в той же группе, имеет полный доступ на чтение и запись. Если в вашей команде несколько приложений - они все потенциально могут читать данные друг друга, если зарегистрированы в одной группе. Это не баг, это фича. Именно поэтому называть группу group.com.yourcompany.shared и класть туда данные от всех приложений - плохая идея.

Ещё один вектор: если extension скомпрометирован (уязвимость в обработке принятого контента), атакующий может записать произвольные данные в shared container, которые основное приложение затем прочитает и обработает. Это классический путь для data injection атаки. Поэтому данные, которые основное приложение читает из контейнера, нужно валидировать так же строго, как данные с сервера.

На macOS ситуация чуть отличается - там App Groups работают через ~/Library/Group Containers/, и Gatekeeper добавляет дополнительные проверки. Но концептуально риски те же.

Как делать безопасно

Первое правило: никаких raw strings в shared container. Я видел код, где токены авторизации хранились как UserDefaults.standard.set(token, forKey: "auth_token") - и это в сьюте, доступном виджету. Если потом окажется, что в этой же группе зарегистрировано ещё одно приложение из той же команды, токен утечёт без каких-либо следов.

Используйте Codable для структурирования данных. Это даёт вам схему, версионирование и возможность валидации:

struct SharedAuthState: Codable {    
  let version: Int   
  let accessTokenHash: String   // только хеш, не сам токен    
  let expiresAt: Date    
  let isAuthenticated: Bool
}

Заметьте - в примере выше хранится хеш токена, а не сам токен. Реальный токен нужно хранить в Keychain с kSecAttrAccessGroup, который тоже можно разделить между таргетами - но это более защищённое хранилище с аппаратным шифрованием на устройствах с Secure Enclave.

Для данных, которые всё же нужно шарить целиком (например, небольшие закешированные ответы API), стоит добавить шифрование поверх. Простой подход - CryptoKit с симметричным ключом из Keychain:

import CryptoKit

func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data {    
  let sealedBox = try AES.GCM.seal(data, using: key)    
  return sealedBox.combined!
}

func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data {    
  let sealedBox = try AES.GCM.SealedBox(combined: data)    
  return try AES.GCM.open(sealedBox, using: key)
}

Ключ при этом хранится в Keychain с kSecAttrAccessible = kSecAttrAccessibleAfterFirstUnlock и kSecAttrAccessGroup для шаринга между таргетами. Сам shared container в этом случае содержит только зашифрованный blob - без ключа он бесполезен.

Обязательная валидация при чтении: проверяйте версию схемы, проверяйте что дата не в прошлом, проверяйте что поля не nil там, где они не должны быть nil. Не доверяйте данным из контейнера больше, чем данным из сети.

Пример: передача состояния авторизации между приложением и виджетом

Покажу конкретную реализацию. Задача: виджет должен знать, авторизован ли пользователь, и показывать либо данные, либо call to action «войдите в приложение».

Сначала модель и менеджер для основного приложения:

// SharedModels.swift (shared target или копия в обоих таргетах)

struct WidgetAuthState: Codable {    
  let version: Int    
  let isAuthenticated: Bool    
  let displayName: String?    
  let tokenExpiresAt: Date?        
  
  static let currentVersion = 1        
  
  var isValid: Bool {        
    guard version == Self.currentVersion else { return false }        
    if isAuthenticated, let expiry = tokenExpiresAt {            
      return expiry > Date()        
    }        
    return true    
  }
}

// SharedStateManager.swift

final class SharedStateManager {    
  static let shared = SharedStateManager()        
  private let groupID = "group.com.yourcompany.myapp"    
  private let stateFileName = "widget_auth_state.encrypted"        
  
  private var containerURL: URL? {        
    FileManager.default.containerURL(            
      forSecurityApplicationGroupIdentifier: groupID        
    )    
  } 
  
  private var stateFileURL: URL? {        
    containerURL?.appendingPathComponent(stateFileName)    
  }        
  
  // Ключ шифрования из Keychain    
  private func encryptionKey() throws -> SymmetricKey {        
    let query: [String: Any] = [            
      kSecClass as String: kSecClassGenericPassword,            
      kSecAttrService as String: "com.yourcompany.myapp.widgetkey",  
      kSecAttrAccessGroup as String: "TEAMID.group.com.yourcompany.myapp", 
      kSecReturnData as String: true        
    ]               
    
    var result: AnyObject?       
    let status = SecItemCopyMatching(query as CFDictionary, &result) 
    
    if status == errSecSuccess, let keyData = result as? Data {            
      return SymmetricKey(data: keyData)        
    }                
    
    // Генерируем новый ключ при первом запуске        
    let newKey = SymmetricKey(size: .bits256)        
    let keyData = newKey.withUnsafeBytes { Data($0) }                
    
    let addQuery: [String: Any] = [            
      kSecClass as String: kSecClassGenericPassword,            
      kSecAttrService as String: "com.yourcompany.myapp.widgetkey", 
      kSecAttrAccessGroup as String: "TEAMID.group.com.yourcompany.myapp", 
      kSecValueData as String: keyData,            
      kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock  
    ]        
    SecItemAdd(addQuery as CFDictionary, nil)                
    
    return newKey    
  }        
  
  func writeAuthState(_ state: WidgetAuthState) throws {        
    guard let url = stateFileURL else {            
      throw SharedStateError.containerUnavailable        
    }                
    
    let key = try encryptionKey()        
    let plaintext = try JSONEncoder().encode(state)        
    let sealedBox = try AES.GCM.seal(plaintext, using: key)                
    
    guard let combined = sealedBox.combined else {            
      throw SharedStateError.encryptionFailed        
    }                
    
    // Атомарная запись через временный файл        
    let tempURL = url.deletingLastPathComponent()            
      .appendingPathComponent(UUID().uuidString)                
    
    try combined.write(to: tempURL, options: .atomic)        
    _ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL)    
  }        
  
  func readAuthState() -> WidgetAuthState? {        
    guard            
      let url = stateFileURL,            
      let encrypted = try? Data(contentsOf: url),            
      let key = try? encryptionKey(),            
      let sealedBox = try? AES.GCM.SealedBox(combined: encrypted),    
      let plaintext = try? AES.GCM.open(sealedBox, using: key), 
      let state = try? JSONDecoder().decode(WidgetAuthState.self, from: plaintext),   
  
      state.isValid        
    else {            
      return nil        
    }                
    
    return state    
  }
}

enum SharedStateError: Error {    
  case containerUnavailable    
  case encryptionFailed
}

В виджете вызываем readAuthState() в getTimeline или getSnapshot. Основное приложение вызывает writeAuthState() при логине/логауте и при обновлении токена.

Обратите внимание на несколько деталей. Во-первых, kSecAttrAccessGroup для Keychain-записи отличается от идентификатора App Group - он имеет формат TEAMID.identifier. Во-вторых, атомарная запись через временный файл защищает от ситуации, когда виджет читает файл в момент, когда основное приложение в середине его перезаписи. В-третьих, isValid на стороне читателя - не опциональная проверка, а обязательная.

Тестирование и отладка

Симулятор работает с App Groups, но есть нюансы. Shared container на симуляторе находится в ~/Library/Developer/CoreSimulator/Devices/<DeviceID>/data/Containers/Shared/AppGroup/. Его легко найти через print(FileManager.default.containerURL(...)) при первом запуске. Полезно открыть эту папку в Finder и наблюдать за файлами в реальном времени - я часто держу Terminal с watch -n1 cat widget_auth_state.encrypted | xxd | head при отладке.

Сбросить данные App Group на симуляторе можно несколькими способами. Самый радикальный - xcrun simctl erase <DeviceID> - стирает весь симулятор. Более хирургически - удалить папку с нужным UUID вручную. Если вы работаете с несколькими симуляторами, имейте в виду, что каждый имеет свой контейнер, даже если bundle ID приложений одинаковый.

Частая проблема при первом запуске extension: контейнер группы не существует, пока ни одно из приложений группы не обратилось к нему. containerURL вернёт URL, но файлы там не будут созданы. Всегда проверяйте, что директория существует, перед записью:

if let url = containerURL {    
  try FileManager.default.createDirectory(        
    at: url,         
    withIntermediateDirectories: true    
  )
}

На реальном устройстве отлаживать сложнее - нет прямого доступа к файловой системе. Используйте os_log и Console.app, либо временно добавьте отображение хеша файла прямо в UI виджета. Instruments с шаблоном File Activity помогает увидеть, когда и какой процесс обращается к файлам контейнера.

Ещё один момент, на котором я обжигался: при обновлении provisioning profile иногда UUID контейнера меняется. Это значит, что все данные в старом контейнере становятся недоступными. Graceful degradation при отсутствии данных - не опция, а необходимость. Виджет должен корректно работать при nil от readAuthState(), показывая состояние «не авторизован» или плейсхолдер.

Итого

App Groups - мощный механизм, который легко использовать небезопасно. Основные тезисы: используйте Keychain для ключей шифрования и чувствительных данных, шифруйте то, что кладёте в shared container, всегда валидируйте читаемые данные, делайте запись атомарной, и не забывайте про graceful degradation. Сложность реализации растёт нелинейно от количества таргетов - чем раньше вы структурируете работу с shared state в отдельный модуль с чёткими интерфейсами, тем меньше головной боли потом.