Если вы разрабатываете 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 → + Capability → App 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 в отдельный модуль с чёткими интерфейсами, тем меньше головной боли потом.
