Я выпустил небольшое 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. На бумаге всё хорошо. На практике у меня были две проблемы:

  1. Setsockopt(IP_TTL) на SOCK_DGRAM ICMP-сокете работает капризно — у меня не все хопы возвращали Time Exceeded, поведение было неконсистентным между разными сетями.

  2. 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-таблицу и показать, кто в локалке”.

Что доступно:

  1. Concurrent TCP connect на популярные порты (80, 443, 22, 8080 и т.д.) — если хост ответил, он живой. Raw SYN недоступен из userspace, поэтому только полноценный TCP handshake.

  2. mDNS / Bonjour discovery через NetServiceBrowser или NWBrowser — но это не полная картина, видны только устройства, которые сами анонсируют сервисы.

  3. 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 это нельзя сделать “правильно”, потому что:

  1. Нет background-thread, который висит постоянно — iOS убивает приложение через ~30 секунд после ухода в background.

  2. Silent push как механизм wake — есть, но требует серверной части, а у меня всё локальное.

  3. 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():

  1. Беру все мониторируемые хосты

  2. Пингую их параллельно (withTaskGroup) — успеть в 30-секундный бюджет

  3. Для каждого: если состояние изменилось (up→down или down→up), шлю local notification

  4. Если состояние не изменилось — молчу, чтобы не спамить

Никаких пушей “хост по-прежнему 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 две беды:

  1. Документация для SwiftUI — никакой. Все примеры на Objective-C или старом UIKit. Что куда подключать в SwiftUI-приложении — додумывал сам.

  2. 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% — для утилитарного приложения, говорят, нормально для старта.


Что я бы сделал по-другому

  1. Не лениться с тестами на парсинге ICMP-хедеров. У меня сейчас тесты только на DNS resolver и whois parser, а ICMP я отлаживал на живых сетях. Stress test от 30 нормально, но один раз я починил баг с big-endian sequence через два дня после релиза, и это стыдно.

  2. Сразу делать UMP до того, как закидывать билд в TestFlight. Если интегрировать UMP после, всё переписывается — порядок инициализации AdMob другой, ATT-prompt порядок меняется и т.д.

  3. Не доверять симулятору для всего что касается background tasks, mDNS, push notifications. У меня было несколько ситуаций, когда симулятор показывал зелёный свет, а на железе — ничего не работало.


Ссылки

Если кто-то делал что-то похожее на iOS — было бы интересно сравнить решения, особенно по background-мониторингу. Пишите в комменты.