Files
punktfunk/clients/apple/Sources/PunktfunkClient/Session/HostWaker.swift
T
enricobuehler 60121f1ea9 feat(apple): wake-until-up overlay + host edit with MAC prefill
- HostWaker + WakeOverlay: after sending the Wake-on-LAN packet, wait until the host
  is really back (resend + mDNS poll, timeout, cancel/retry) before connecting.
  macOS-only in practice — WoL stays gated off on iOS/tvOS pending the multicast
  entitlement.
- Add/Edit host sheet gains a Wake-on-LAN MAC field, prefilled from the stored MAC
  or the live mDNS advert; parseMacs validates aa:bb:cc:dd:ee:ff.
- Gamepad chrome/home and glass-style polish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:05:17 +02:00

113 lines
4.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Wake a sleeping host and WAIT for it to come back before proceeding.
//
// A magic packet is fire-and-forget, and a cold box can take 2060 s to POST, boot, and start
// advertising on mDNS again — far longer than a connect attempt will sit. The old path fired a
// packet and immediately dialed, so a genuinely-asleep host just failed. This drives a visible
// "Waking…" state instead: it (re-)sends the packet, polls the host's mDNS presence once a second,
// and on success runs `onOnline` (the real connect for a Wake-&-Connect, or nothing for an explicit
// wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
import Foundation
import PunktfunkKit
import SwiftUI
@MainActor
final class HostWaker: ObservableObject {
struct Waking: Equatable {
let hostID: UUID
let hostName: String
/// Whether coming online chains into a connect (Wake & Connect) vs. just stopping.
let connectsAfter: Bool
var seconds = 0
var timedOut = false
}
/// nil = idle; non-nil drives `WakeOverlay`.
@Published private(set) var waking: Waking?
/// How long to wait for the host to reappear before giving up. Generous — a cold boot + service
/// start can be a minute-plus.
private let timeoutSeconds = 90
/// Re-send the packet this often: a single one can be missed, and some NICs only wake on a fresh
/// packet after dropping into a deeper sleep state.
private let resendEverySeconds = 6
private var loop: Task<Void, Never>?
/// Captured so "Try Again" replays the exact same wait.
private var replay: (() -> Void)?
/// Wake `host` and wait for `isOnline()` to go true, then run `onOnline`. `macs`/`lastIP` target
/// the magic packet. No-ops straight to `onOnline` when there's nothing to wake with or the host
/// is already up (a race between the caller's check and here).
func start(
host: StoredHost, connectsAfter: Bool,
macs: [String], lastIP: String?,
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
) {
guard !macs.isEmpty, !isOnline() else {
cancel()
onOnline()
return
}
replay = { [weak self] in
self?.run(host: host, connectsAfter: connectsAfter, macs: macs, lastIP: lastIP,
isOnline: isOnline, onOnline: onOnline)
}
replay?()
}
/// Stop waiting and dismiss the overlay (B / Cancel).
func cancel() {
loop?.cancel()
loop = nil
replay = nil
waking = nil
}
/// Restart the wait after a timeout (A / Try Again).
func retry() { replay?() }
private func run(
host: StoredHost, connectsAfter: Bool, macs: [String], lastIP: String?,
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
) {
loop?.cancel()
waking = Waking(hostID: host.id, hostName: host.displayName, connectsAfter: connectsAfter)
let timeout = timeoutSeconds
let resend = resendEverySeconds
loop = Task { [weak self] in
var elapsed = 0
while !Task.isCancelled {
if elapsed % resend == 0 { Self.sendPacket(macs: macs, lastIP: lastIP) }
if isOnline() {
guard let self, !Task.isCancelled else { return }
self.waking = nil
self.loop = nil
onOnline()
return
}
if elapsed >= timeout {
self?.waking?.timedOut = true
self?.loop = nil
return
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
elapsed += 1
self?.waking?.seconds = elapsed
}
}
}
/// Blocking sends (see PunktfunkConnection.wakeOnLAN) — off the main thread.
private static func sendPacket(macs: [String], lastIP: String?) {
DispatchQueue.global(qos: .userInitiated).async {
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: lastIP)
}
}
#if DEBUG
/// Force a static waking state for the screenshot harness (no timers, no packets).
func debugSet(_ w: Waking) { waking = w }
#endif
}