Kako VPN Russia ograničava besplatnu sesiju na 15 minuta na iOS-u i macOS-u: prozor sesije u App Group-u, čuvar unutar NEPacketTunnelProvider-a, sleep i wake i isključenje koje On Demand ne može da poništi.
Naša aplikacija VPN Russia ima besplatni režim sa jednostavnim pravilom: 15 minuta veze, zatim 45 minuta pauze. Pretplata uklanja ograničenje. Tajmer deluje kao sitnica, ali krije pravo pitanje: koji proces njime upravlja? U nastavku je opisano kako to radi u našem kodu za iOS i macOS, sa primerima na Swift-u. Verzija za Android je u posebnom tekstu.
Zašto tajmer ne može da živi u aplikaciji
Očigledno mesto je sama aplikacija: pokrenuti Timer pri povezivanju i pozvati stopVPNTunnel() kada istekne. To puca u tri česte situacije:
- Aplikacija ode u pozadinu i iOS je za nekoliko sekundi suspenduje. U suspendovanoj aplikaciji tajmeri ne rade.
- Korisnik prevuče aplikaciju ili je sistem ugasi zbog memorije. Tunel i dalje radi, a tajmera više nema.
- Tunel može da se podigne i bez aplikacije: prekidačem za VPN u Podešavanjima ili po pravilima Connect On Demand.
Jedini proces koji živi tačno onoliko koliko i tunel jeste sama Network Extension ekstenzija, naslednik NEPacketTunnelProvider-a. Zato tajmer pripada ekstenziji i ona sama gasi tunel. Aplikacija samo čita isto stanje da bi nacrtala interfejs i odbila povezivanje tokom pauze.
Prozor sesije umesto odbrojavanja
Ekstenzija i aplikacija su različiti procesi, pa stanje ide u UserDefaults zajedničke App Group grupe, koju vide oba. Čuvamo dva datuma, a ne preostalo vreme:
allowedDate— trenutak kada se završava besplatna sesija;nextReconnectDate— trenutak kada se završava pauza i može da počne nova sesija.
Datumi preživljavaju svako ponovno pokretanje oba procesa i sami daju glavno pravilo: ponovno povezivanje unutar prozora ga ne produžava. Ako se isključite u desetom minutu i ponovo povežete, ostaje vam pet minuta, a ne petnaest.
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 }
/// Povezivanje je zabranjeno samo između kraja sesije i kraja pauze.
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")
}
}Niko neće čekati četvrt sata da bi proverio tajmer, pa debug verzija koristi kraće vrednosti:
#if DEBUG
static let k_FreeSessionPauseInMinutes = 1
static let k_FreeSessionDurationInSeconds = 120
#else
static let k_FreeSessionPauseInMinutes = 45
static let k_FreeSessionDurationInSeconds = 900
#endifPokretanje čuvara u startTunnel
Čuvar se pokreće kada je tunel zaista podignut — odmah posle uspešnog setTunnelNetworkSettings. Pretplatnike ne dira: Archive.isPremium se čita iz iste App Group grupe.
// unutar startTunnel, posle primene mrežnih podešavanja
try await setTunnelNetworkSettings(settings)
addConnectionObserver()
private func addConnectionObserver() {
guard !Archive.isPremium else { return }
perform()
}
private func perform() {
// Prvo povezivanje otvara prozor
if ConnectionValidator.allowedDate == nil {
ConnectionValidator.newConnection()
}
guard ConnectionValidator.canConnect else {
// Podignut tokom pauze (On Demand, prekidač u Podešavanjima): gasimo odmah
disconnect()
return
}
guard let allowedDate = ConnectionValidator.allowedDate,
let nextReconnectDate = ConnectionValidator.nextReconnectDate else { return }
if nextReconnectDate < .now {
// Stari prozor i njegova pauza su prošli: počinje nova sesija
ConnectionValidator.resetConnection()
perform()
} else {
startDisconnectTimer(allowedDate: allowedDate)
}
}Obratite pažnju: prozor otvara sama ekstenzija. Aplikacija možda uopšte ne učestvuje u pokretanju, a ograničenje mora da radi.
Tajmer koji preživljava spavanje uređaja
Prva ideja je jedan tajmer za ceo ostatak: deadline: .now() + remaining. Nemojte. DispatchTime broji vreme rada uređaja (uptime), a ono stoji dok uređaj spava. Zaključajte iPhone na sat vremena i tajmer postavljen na deset minuta okinuće posle deset minuta budnog rada — sesija će tiho porasti za celo vreme spavanja.
Umesto toga, tajmer otkucava svakih pet sekundi i poredi kalendarski sat sa allowedDate. Otkucaji mogu da plivaju, poređenje ne može.
private var disconnectTimer: DispatchSourceTimer?
private func startDisconnectTimer(allowedDate: Date) {
// Proces ekstenzije može biti ponovo iskorišćen: dva tajmera istovremeno nisu dozvoljena
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
}Tolerancija od jedne sekunde (leeway) dozvoljava sistemu da grupiše buđenja — za ekstenziju koja radi satima to se vidi na bateriji.
NEPacketTunnelProvider uz to javlja kada uređaj zaspi i kada se probudi. Oba trenutka su dobra prilika da se proveri rok bez čekanja sledećeg otkucaja:
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()
}
}Isključiti tako da ostane isključeno
Zaustaviti VPN jezgro ili pozvati cancelTunnelWithError nije dovoljno: profil ostaje uključen, i ako ima pravila Connect On Demand, iOS odmah ponovo podiže tunel. Zato isključujemo konfiguraciju. Prekidač za VPN u Podešavanjima se gasi, sistem zaustavlja tunel sa razlogom configurationDisabled i ne pokreće ga ponovo.
private func disconnect() {
// Sistem će prijaviti sopstveni razlog zaustavljanja; pamtimo
// da je u pitanju bilo ograničenje besplatne sesije, zbog analitike
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()
// Ako je pokretanje još u toku, sačekamo ga pre zaustavljanja jezgra
await waitForStartToFinish()
stopActiveCore(context: "disconnect()")
}
}
override func stopTunnel(with reason: NEProviderStopReason) async {
disconnectTimer?.cancel()
disconnectTimer = nil
await waitForStartToFinish()
stopActiveCore(context: "stopTunnel")
}Upozorenje
isEnabled = false isključuje VPN profil, pa sledeće povezivanje iz aplikacije mora ponovo da ga uključi pre poziva startVPNTunnel(). Naš connect() proverava manager.isEnabled i, ako je profil isključen, postavlja true i prvo čuva podešavanja.
Dva detalja iz produkcije. Prvo: otkažite tajmer u stopTunnel. Korisnik može da isključi VPN sekundu pre nego što tajmer okine, a zakasneli disconnect() napraviće trku sa običnim zaustavljanjem. Drugo: zapišite zašto se sesija završila. Sistem prijavljuje zaustavljanje kao isključenu konfiguraciju, i bez sopstvene oznake analitika ne može da razlikuje ograničenje besplatne sesije od korisnika koji je sam isključio VPN u Podešavanjima.
Strana aplikacije: odbiti i objasniti
Aplikacija ne gasi tunel zbog ograničenja — to radi ekstenzija. Ali pre povezivanja proverava isti prozor i tokom pauze pokazuje koliko još treba čekati, umesto da podiže tunel koji bi odmah bio ugašen:
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))
}
}Kontrolna lista
- Tajmer pripada ekstenziji tunela, a ne aplikaciji.
- Čuvajte kraj sesije i kraj pauze kao datume u App Group-u, a ne preostalo vreme.
- Otkucavajte često i poredite sa kalendarskim satom: jedan dugi tajmer na
DispatchTimerasteže se za vreme spavanja. - Proveravajte rok i u
sleep()iwake(). - Isključujte tako što isključite konfiguraciju, inače će vas On Demand ponovo povezati.
- Otkažite tajmer u
stopTunneli pre pravljenja novog. - Pretplatnicima se prozor ne otvara: posle otkazivanja pretplate neće naleteti na staro ograničenje.
- U debug verziji koristite kratke intervale.
Ista logika na Androidu — sa VpnService-om, DataStore-om i korutinama umesto ekstenzije i dispatch tajmera — nalazi se u tekstu o Androidu.
ONLYAPPS Team
Novi Sad, Srbija · onlyapps.org