Как VPN Russia ограничивает бесплатную сессию 15 минутами на iOS и macOS: окно сессии в App Group, сторож внутри NEPacketTunnelProvider, sleep и wake и отключение, которое не отменит On Demand.
В нашем приложении VPN Russia есть бесплатный режим с простым правилом: 15 минут подключения, затем 45 минут паузы. Подписка снимает ограничение. Таймер кажется пустяком, но за ним прячется настоящий вопрос: какой процесс им владеет? Ниже — как это устроено в нашем коде для iOS и macOS, с примерами на Swift. Версия для Android — в отдельной статье.
Почему таймер нельзя держать в приложении
Очевидное место — само приложение: запустить Timer при подключении и вызвать stopVPNTunnel(), когда он сработает. Это ломается в трёх частых ситуациях:
- Приложение уходит в фон, и iOS через несколько секунд его приостанавливает. В приостановленном приложении таймеры не идут.
- Пользователь смахивает приложение, или система выгружает его ради памяти. Туннель продолжает работать, а таймера больше нет.
- Туннель вообще может подняться без приложения: переключателем VPN в «Настройках» или по правилам Connect On Demand.
Единственный процесс, который живёт ровно столько же, сколько туннель, — само расширение Network Extension, наследник NEPacketTunnelProvider. Поэтому таймер принадлежит расширению, и туннель оно снимает само. Приложение лишь читает то же состояние, чтобы нарисовать интерфейс и не дать подключиться во время паузы.
Окно сессии вместо обратного отсчёта
Расширение и приложение — разные процессы, поэтому состояние лежит в UserDefaults общей App Group, которую видят оба. Храним две даты, а не оставшееся время:
allowedDate— момент, когда заканчивается бесплатная сессия;nextReconnectDate— момент, когда заканчивается пауза и можно начать новую сессию.
Даты переживают любой перезапуск обоих процессов и бесплатно дают главное правило: переподключение внутри окна его не продлевает. Отключились на десятой минуте и подключились снова — осталось пять минут, а не пятнадцать.
final class ConnectionValidator {
static var defaults: UserDefaults {
UserDefaults(suiteName: SharedConfig.k_AppGroup) ?? .standard
}
static var allowedDate: Date? { defaults.object(forKey: "allowedDate") as? Date }
static var nextReconnectDate: Date? { defaults.object(forKey: "nextReconnectDate") as? Date }
/// Подключение запрещено только между концом сессии и концом паузы.
static var canConnect: Bool {
if let allowedDate, let nextReconnectDate {
let now = Date.now
if nextReconnectDate > now, allowedDate < now { return false }
}
return true
}
static func newConnection() {
let allowedDate = Date.now.addingTimeInterval(TimeInterval(SharedConfig.k_FreeSessionDurationInSeconds))
let barrierDate = Calendar.current.date(
byAdding: .minute, value: SharedConfig.k_FreeSessionPauseInMinutes, to: allowedDate
)
defaults.set(allowedDate, forKey: "allowedDate")
defaults.set(barrierDate, forKey: "nextReconnectDate")
}
static func resetConnection() {
defaults.set(nil, forKey: "allowedDate")
defaults.set(nil, forKey: "nextReconnectDate")
}
}Ждать четверть часа ради проверки таймера никто не будет, поэтому в отладочной сборке значения короче:
#if DEBUG
static let k_FreeSessionPauseInMinutes = 1
static let k_FreeSessionDurationInSeconds = 120
#else
static let k_FreeSessionPauseInMinutes = 45
static let k_FreeSessionDurationInSeconds = 900
#endifЗапуск сторожа в startTunnel
Сторож стартует, когда туннель действительно поднялся, — сразу после успешного setTunnelNetworkSettings. Подписчиков он не касается: Archive.isPremium читается из той же App Group.
// внутри startTunnel, после применения сетевых настроек
try await setTunnelNetworkSettings(settings)
addConnectionObserver()
private func addConnectionObserver() {
guard !Archive.isPremium else { return }
perform()
}
private func perform() {
// Первое подключение открывает окно
if ConnectionValidator.allowedDate == nil {
ConnectionValidator.newConnection()
}
guard ConnectionValidator.canConnect else {
// Подняли во время паузы (On Demand, переключатель в «Настройках»): снимаем сразу
disconnect()
return
}
guard let allowedDate = ConnectionValidator.allowedDate,
let nextReconnectDate = ConnectionValidator.nextReconnectDate else { return }
if nextReconnectDate < .now {
// Старое окно и его пауза закончились: начинаем новую сессию
ConnectionValidator.resetConnection()
perform()
} else {
startDisconnectTimer(allowedDate: allowedDate)
}
}Обратите внимание: окно открывает само расширение. Приложение может вообще не участвовать в запуске, а ограничение должно работать.
Таймер, который переживает сон устройства
Первая идея — один таймер на весь остаток: deadline: .now() + remaining. Не надо. DispatchTime считает время работы устройства (uptime), а оно стоит, пока устройство спит. Заблокируйте iPhone на час — и таймер, заведённый на десять минут, сработает через десять минут бодрствования: сессия молча вырастет на всё время сна.
Вместо этого таймер тикает каждые пять секунд и сравнивает календарные часы с allowedDate. Сами тики могут плыть, сравнение — нет.
private var disconnectTimer: DispatchSourceTimer?
private func startDisconnectTimer(allowedDate: Date) {
// Процесс расширения бывает переиспользован: два таймера одновременно недопустимы
disconnectTimer?.cancel()
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .userInitiated))
timer.schedule(deadline: .now() + 5, repeating: 5, leeway: .seconds(1))
timer.setEventHandler { [weak self] in
guard Date.now >= allowedDate else { return }
self?.disconnectTimer?.cancel()
self?.disconnectTimer = nil
self?.disconnect()
}
timer.resume()
disconnectTimer = timer
}Допуск в одну секунду (leeway) позволяет системе группировать пробуждения — для расширения, которое работает часами, это заметно по батарее.
NEPacketTunnelProvider к тому же сообщает, когда устройство засыпает и просыпается. И то и другое — хороший момент проверить срок, не дожидаясь следующего тика:
override func sleep(completionHandler: @escaping () -> Void) {
if !Archive.isPremium,
let allowedDate = ConnectionValidator.allowedDate,
Date.now >= allowedDate {
disconnect()
}
completionHandler()
}
override func wake() {
guard !Archive.isPremium else { return }
if let allowedDate = ConnectionValidator.allowedDate, Date.now >= allowedDate {
disconnect()
}
}Отключить так, чтобы не подключилось обратно
Остановить ядро VPN или вызвать cancelTunnelWithError мало: профиль остаётся включённым, и если у него есть правила Connect On Demand, iOS тут же поднимет туннель снова. Поэтому мы выключаем конфигурацию. Переключатель VPN в «Настройках» гаснет, система останавливает туннель с причиной configurationDisabled и не перезапускает его.
private func disconnect() {
// Система сообщит свою причину остановки; запоминаем,
// что это был лимит бесплатной сессии, — для аналитики
TunnelStopRecord.markFreeLimit(in: Archive.defaults)
Task {
guard let provider = await NETunnelProviderManager.fetchProvider() else { return }
provider.connection.manager.isEnabled = false
try? await provider.connection.manager.saveToPreferences()
// Если старт ещё в процессе, дожидаемся его, прежде чем останавливать ядро
await waitForStartToFinish()
stopActiveCore(context: "disconnect()")
}
}
override func stopTunnel(with reason: NEProviderStopReason) async {
disconnectTimer?.cancel()
disconnectTimer = nil
await waitForStartToFinish()
stopActiveCore(context: "stopTunnel")
}Внимание
isEnabled = false выключает VPN-профиль, поэтому следующее подключение из приложения должно снова его включить до вызова startVPNTunnel(). Наш connect() проверяет manager.isEnabled и, если профиль выключен, ставит true и сначала сохраняет настройки.
Две детали из боевой эксплуатации. Первая: отменяйте таймер в stopTunnel. Пользователь может выключить VPN за секунду до срабатывания, и запоздалый disconnect() устроит гонку с обычной остановкой. Вторая: записывайте, почему сессия закончилась. Система сообщает об остановке как о выключенной конфигурации, и без собственной отметки аналитика не отличит лимит бесплатной сессии от пользователя, который сам выключил VPN в «Настройках».
Сторона приложения: отказать и объяснить
Из-за лимита приложение туннель не останавливает — это делает расширение. Но перед подключением оно проверяет то же окно и во время паузы показывает, сколько осталось ждать, вместо того чтобы поднимать туннель, который тут же снимут:
if Archive.isPremium || ConnectionValidator.canConnect {
Task { try? await vpnManager.connect() }
} else if let nextReconnectDate = ConnectionValidator.nextReconnectDate,
nextReconnectDate > .now {
let left = Calendar.current.dateComponents([.minute, .second], from: .now, to: nextReconnectDate)
if let minutes = left.minute, let seconds = left.second {
alertIdentifier = .init(choice: .limit(minutes: minutes, seconds: seconds))
}
}Чек-лист
- Таймер принадлежит расширению туннеля, а не приложению.
- Храните конец сессии и конец паузы датами в App Group, а не остаток времени.
- Тикайте часто и сверяйтесь с календарными часами: один длинный таймер на
DispatchTimeрастягивается на время сна. - Проверяйте срок и в
sleep()иwake(). - Отключайте через выключение конфигурации, иначе On Demand подключит обратно.
- Отменяйте таймер в
stopTunnelи перед созданием нового. - Подписчикам окно не заводится: после отмены подписки они не упрутся в старый лимит.
- В отладочной сборке используйте короткие интервалы.
Та же логика на Android — с VpnService, DataStore и корутинами вместо расширения и dispatch-таймеров — в статье про Android.
ONLYAPPS Team
Нови-Сад, Сербия · onlyapps.org