Я выпустил небольшое iOS-приложение — NetDiag+. Это набор сетевых утилит: ping, traceroute, DNS lookup, whois, LAN-сканер, port scanner, проверка SSL-сертификатов, BGP/ASN lookup, Wi-Fi info и фоновый мониторинг хостов с пушами при падении. Я начинал его как пет-проект для собственных нужд, потому что на iOS приходилось переключаться между четырьмя разными приложениями для базовой диагностики, и в трёх из четырёх была реклама.
Хочу поделиться тем, что мне самому хотелось бы прочитать в начале — почему некоторые вещи на iOS работают не так, как ожидаешь от Unix-фона, и где грабли лежат не там, где кажется.
Сразу спойлер по выводам: самым болезненным оказалось не сетевое программирование, а интеграция UMP-консента для AdMob.
Стек
SwiftUI, iOS 16+, чистый Swift Concurrency (async/await, AsyncStream, TaskGroup), Darwin C-API там где деваться некуда. Никаких сторонних сетевых библиотек — всё на голых BSD-сокетах через C-интероп.
ICMP ping без entitlement
Первое, что удивило: на iOS можно открыть ICMP-сокет без специальных entitlements и без рута, чего нельзя сделать на классическом Linux без CAP_NET_RAW или setuid. Apple сделала эту возможность доступной для обычных пользовательских процессов через SOCK_DGRAM (а не SOCK_RAW):
final class ICMPSocket { private let fd: Int32 init() throws { fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP) guard fd >= 0 else { throw SocketError.creationFailed(errno) } } deinit { close(fd) } // ... }
Это та же модель, что использует macOS-овский ping(8) без setuid начиная с какой-то из старых версий macOS. Ядро само пишет правильный ICMP-header за тебя (тип/код/checksum для echo request), а identifier подменяет на сгенерированный — поэтому на recv ты получаешь не тот ID, что отправлял, и стандартная логика matching по identifier не работает. Это первая грабля.
Решение — matching по sequence number и payload. Я кладу свой маркер в payload и проверяю его на приёме:
// Send var header = ICMPHeader() header.type = ICMPType.echoRequest.rawValue header.code = 0 header.identifier = 0 // ядро перепишет header.sequence = currentSequence.bigEndian let payload = makePayload(sequence: currentSequence) let packet = header.bytes + payload try socket.send(data: packet, to: address) // Receive let (data, fromIP) = try socket.receive() guard let received = ICMPHeader.parse(data), received.type == ICMPType.echoReply.rawValue, received.sequence == currentSequence.bigEndian else { continue }
Вторая грабля — таймауты. recvfrom без SO_RCVTIMEO блокируется навсегда, что в Swift Concurrency означает зависший Task, который потом нельзя нормально отменить. Я ставлю таймаут на сокет:
func setTimeout(seconds: Double) { var tv = timeval( tv_sec: Int(seconds), tv_usec: Int32((seconds.truncatingRemainder(dividingBy: 1)) * 1_000_000) ) setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size)) }
И на EAGAIN/EWOULDBLOCK бросаю свой SocketError.timeout, который выше по стеку превращается в нормальный пинговый “timeout” hop.
Apple когда-то выложила пример SimplePing — это OG-референс на Objective-C, у которого многое можно подсмотреть, но он плохо ложится в современный Swift Concurrency и неудобен для multi-host пингов. Я в итоге написал свою тонкую обёртку и не жалею.
Traceroute, который реально работает
Стандартный Unix-traceroute может работать тремя способами: ICMP echo (как Windows tracert), UDP к закрытым портам (классический BSD-вариант) или TCP SYN (paris/traceroute-tcp).
На iOS я попробовал ICMP-варинт первым. Идея простая: отправляешь ICMP echo с маленьким TTL, ждёшь ICMP Time Exceeded от промежуточного хопа, увеличиваешь TTL. На бумаге всё хорошо. На практике у меня были две проблемы:
Setsockopt(IP_TTL) на SOCK_DGRAM ICMP-сокете работает капризно — у меня не все хопы возвращали Time Exceeded, поведение было неконсистентным между разными сетями.
Matching входящих ответов — на receive ты получаешь обратно ICMP Time Exceeded, внутри которого лежит твой исходный пакет. Чтобы сопоставить ответ конкретному TTL-шагу, нужно парсить inner-payload, что мне показалось хрупко.
Поэтому я переключился на классический BSD-подход: UDP-пакеты на закрытые порты с увеличивающимся TTL, и параллельно слушаем ICMP-сокет на входящие Time Exceeded:
let icmpSock = try ICMPSocket() // только для приёма icmpSock.setTimeout(seconds: timeout) for ttl in 1...maxHops { let udpSock = try UDPSocket() udpSock.setTTL(ttl) var dest = address dest.sin_port = (port + UInt16(ttl - 1)).bigEndian // 33434, 33435, ... let probe = Data(repeating: 0x40, count: 32) let sendTime = CFAbsoluteTimeGetCurrent() try udpSock.send(data: probe, to: dest) let (data, fromIP) = try icmpSock.receive() let rtt = CFAbsoluteTimeGetCurrent() - sendTime // ... }
Порты 33434+ttl — это исторический выбор traceroute(8) ещё с 80-х: маловероятно, что они открыты на конечном хосте, поэтому ты гарантированно получаешь либо ICMP Time Exceeded от промежуточного хопа, либо ICMP Port Unreachable от destination — оба варианта обрабатываются единообразно.
Что важно понимать: на iOS UDP_TTL отлично работает через setsockopt(IP_TTL), в отличие от ICMP-варианта. И ICMP-сокет используется только как пассивный приёмник — мы из него ничего не шлём, только читаем входящие Time Exceeded.
Финальный хоп определяю по двум условиям:
let isFinal = fromIP == destIP || header.type == ICMPType.destinationUnreachable.rawValue
То есть либо ответ пришёл от целевого IP (например если сам destination прислал ICMP Port Unreachable), либо тип ICMP — Destination Unreachable.
LAN-сканер: ARP недоступен
Здесь iOS режет жёстче. Тебе не отдают ARP-таблицу системы в userspace. Нет API типа getarp(), нет /proc/net/arp как на Linux. Поэтому забудьте про идею “просто прочитать ARP-таблицу и показать, кто в локалке”.
Что доступно:
Concurrent TCP connect на популярные порты (80, 443, 22, 8080 и т.д.) — если хост ответил, он живой. Raw SYN недоступен из userspace, поэтому только полноценный TCP handshake.
mDNS / Bonjour discovery через
NetServiceBrowserилиNWBrowser— но это не полная картина, видны только устройства, которые сами анонсируют сервисы.NSLocalNetworkUsageDescription в Info.plist — обязательно, иначе при первой попытке коннекта на локальный IP iOS откроет alert “Allow Local Network Access”, и до ответа пользователя ничего не работает.
Я пошёл по первому пути — параллельный TCP connect через withThrowingTaskGroup:
private static let maxConcurrentProbes = 20 func discoverHosts(localIP: String, subnetMask: String) -> AsyncThrowingStream<LANDevice, Error> { AsyncThrowingStream { continuation in Task.detached { let range = Self.calculateSubnetRange(ip: localIP, mask: subnetMask) try await withThrowingTaskGroup(of: LANDevice?.self) { group in var activeCount = 0 for ip in range { if activeCount >= Self.maxConcurrentProbes { if let device = try? await group.next() { if let d = device { continuation.yield(d) } } activeCount -= 1 } group.addTask { try await Self.probeHost(ip: ip) } activeCount += 1 } for try await device in group { if let d = device { continuation.yield(d) } } } continuation.finish() } } }
Один connect() на порт 80 с таймаутом 500мс на каждый IP в /24 — хост либо ответит SYN+ACK (живой), либо RST (живой, но порт закрыт — тоже считаю живым), либо таймаут (мёртвый или фильтрует). За счёт maxConcurrentProbes = 20 весь /24 пробегается за пару секунд.
Что я не делаю, но мог бы:
Не использую mDNS для дополнения списка (есть в TODO)
Не делаю port scanning по умолчанию — это уже отдельная функция в приложении
Грабля, на которую я наступил: на iOS 17 alert “Local Network Access” показывается только один раз. Если пользователь отказал — следующая попытка connect() молча таймаутится, без явной ошибки. Пришлось делать UI-подсказку про настройки.
Host Monitor: BGTaskScheduler как он есть
Самая инженерно неприятная часть приложения. Пользователь хочет: “добавляю хост, и если он упадёт — мне приходит пуш, как у UptimeRobot”. На iOS это нельзя сделать “правильно”, потому что:
Нет background-thread, который висит постоянно — iOS убивает приложение через ~30 секунд после ухода в background.
Silent push как механизм wake — есть, но требует серверной части, а у меня всё локальное.
BGTaskScheduler— даёт ~30 секунд CPU может быть раз в 15+ минут. iOS сам решает, когда тебя запустить, на основе паттернов использования.
То есть точность мониторинга на iOS принципиально хуже, чем на сервере. Это надо честно признать и сделать дизайн исходя из этого.
Что я делаю:
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.netdiag.hostmonitor", using: nil) { task in Task { await HostMonitorService.shared.runMonitoringPass() task.setTaskCompleted(success: true) } submitNext() // переподаём задачу на следующее выполнение } let request = BGAppRefreshTaskRequest(identifier: "com.netdiag.hostmonitor") request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // не раньше чем через 15 мин try BGTaskScheduler.shared.submit(request)
И внутри runMonitoringPass():
Беру все мониторируемые хосты
Пингую их параллельно (
withTaskGroup) — успеть в 30-секундный бюджетДля каждого: если состояние изменилось (up→down или down→up), шлю local notification
Если состояние не изменилось — молчу, чтобы не спамить
Никаких пушей “хост по-прежнему up каждые 5 минут”, только state transitions. Это и битву с iOS background-лимитами помогает выиграть, и пользователю не надоедает.
Грабля: BGTaskScheduler не работает в симуляторе. Точнее, работает только если ты руками дёрнешь его через debugger:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.netdiag.hostmonitor"]
Я нашёл это в каком-то DTS-thread, и без этой команды отлаживать background-логику невозможно. На реальном устройстве система запускает таски, но непредсказуемо.
AdMob и UMP — то, что бесило больше всего
Сетевая часть писалась с удовольствием. Интеграция AdMob — отдельная история.
Сам AdMob через GoogleMobileAds Swift Package заводится без боли. MobileAds.shared.start() — и баннеры работают через BannerView в UIViewRepresentable. Стандартно.
Проблемы начинаются с GDPR / CCPA консента, который Google требует через свой UMP SDK (GoogleUserMessagingPlatform). У этого SDK две беды:
Документация для SwiftUI — никакой. Все примеры на Objective-C или старом UIKit. Что куда подключать в SwiftUI-приложении — додумывал сам.
AdMob не активирует privacy message, пока в приложении нет “revocation link” — то есть кнопки в Settings, которая открывает форму повторного согласия. Логика бизнес-процесса такая: сначала ты в коде делаешь revocation link, выкатываешь сборку, потом возвращаешься в AdMob и подтверждаешь “да, у меня revocation link есть, активируйте сообщение”. Об этом нигде явно не сказано — я неделю сидел с настроенным, но неактивным consent message, пока не понял.
В коде это выглядит так:
@MainActor final class ConsentManager: ObservableObject { static let shared = ConsentManager() @Published private(set) var adsStarted = false @Published private(set) var privacyOptionsRequired = false func requestConsentAndStartAds() { let parameters = RequestParameters() ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { [weak self] error in ConsentForm.loadAndPresentIfRequired(from: Self.topViewController()) { [weak self] formError in self?.updatePrivacyOptionsAvailability() self?.startAdsIfNeeded() } } } func presentPrivacyOptionsForm() { guard let root = Self.topViewController() else { return } ConsentForm.presentPrivacyOptionsForm(from: root) { [weak self] error in self?.updatePrivacyOptionsAvailability() } } private func startAdsIfNeeded() { guard ConsentInformation.shared.canRequestAds else { return } MobileAds.shared.start { [weak self] _ in self?.adsStarted = true } } // ... }
Ключевая часть — MobileAds.shared.start() вызывается только после того, как UMP-flow вернул canRequestAds == true. Если пользователь в EEA отказал — canRequestAds всё равно true, просто реклама будет неперсонализированная. Один и тот же flow покрывает и GDPR (EEA/UK), и US state privacy (CCPA для Калифорнии и других регулируемых штатов), потому что UMP под капотом смотрит на geo и подбирает соответствующий privacy message.
Ещё одна грабля — ATTrackingManager.requestTrackingAuthorization (ATT prompt) должен показываться до UMP-flow, потому что UMP смотрит на ATT-статус при формировании запроса:
ATTManager.requestTrackingIfNeeded { ConsentManager.shared.requestConsentAndStartAds() }
Если делать в обратном порядке — UMP может посчитать, что трекинг разрешён, а потом ATT откажет, и получится несоответствие, которое AdMob потом может зафлажить.
Для отладки в Debug я форсирую geography:
#if DEBUG let debugSettings = DebugSettings() debugSettings.geography = .EEA // или .regulatedUSState parameters.debugSettings = debugSettings #endif
Без этого на российском IP UMP-форма не показывается, и проверить flow было нельзя.
Локализация
12 языков (en, ru, de, es, pt-BR, fr, it, pl, ja и ещё три) сделал через новые Xcode String Catalogs (.xcstrings). После старых Localizable.strings это просто счастье — JSON-формат, нормальный diff в git, GUI в Xcode для перевода, автоматический pluralization. Если кто-то ещё держится за старые .strings — попробуйте мигрировать, ничего не теряете.
Числа на момент написания
App Store одобрил приложение в апреле 2026. На момент публикации этой статьи — около 30 установок (т.е. почти ноль маркетинга). Эта статья — часть маркетингового эксперимента: технический deep-dive на правильную аудиторию против попыток купить ASO.
AdMob после первой недели после approval: eCPM $1.19, match rate 85% — для утилитарного приложения, говорят, нормально для старта.
Что я бы сделал по-другому
Не лениться с тестами на парсинге ICMP-хедеров. У меня сейчас тесты только на DNS resolver и whois parser, а ICMP я отлаживал на живых сетях. Stress test от 30 нормально, но один раз я починил баг с big-endian sequence через два дня после релиза, и это стыдно.
Сразу делать UMP до того, как закидывать билд в TestFlight. Если интегрировать UMP после, всё переписывается — порядок инициализации AdMob другой, ATT-prompt порядок меняется и т.д.
Не доверять симулятору для всего что касается background tasks, mDNS, push notifications. У меня было несколько ситуаций, когда симулятор показывал зелёный свет, а на железе — ничего не работало.
Ссылки
App Store: https://apps.apple.com/us/app/netdiag-network-toolkit/id6761954529
Apple’s SimplePing reference: https://developer.apple.com/library/archive/samplecode/SimplePing/Introduction/Intro.html
BGTaskScheduler docs: https://developer.apple.com/documentation/backgroundtasks
UMP SDK docs: https://developers.google.com/admob/ios/privacy
Если кто-то делал что-то похожее на iOS — было бы интересно сравнить решения, особенно по background-мониторингу. Пишите в комменты.