Я выпустил небольшое 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. У меня было несколько ситуаций, когда симулятор показывал зелёный свет, а на железе — ничего не работало.
Проверять UIBackgroundModes, а не только BGTaskSchedulerPermittedIdentifiers. Если идентификатор объявлен, а режима fetch в UIBackgroundModes нет, то BGTaskScheduler.submit() возвращает успех, а система молча выбрасывает заявку. На устройстве это выглядит так: submit → YES, а следом _simulateLaunchForTaskWithIdentifier отвечает «No task request has been scheduled». Ни ошибки, ни лога. Фоновые задачи просто никогда не запускаются — у меня так тихо не работали пуши мониторинга хостов с самого их появления (нашёл только в августе 2026).
Ссылки
Разбор Path MTU на iPhone — таблица типовых значений и ручной метод через ping:
Где поле MTU в роутерах Huawei / ZTE / TP‑Link / Zyxel / Nokia
Если кто‑то делал что‑то похожее на iOS — было бы интересно сравнить решения, особенно по background‑мониторингу. Пишите в комменты.
