88348153f3
apple / swift (push) Successful in 1m10s
arch / build-publish (push) Successful in 5m17s
android / android (push) Successful in 7m30s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m11s
release / apple (push) Successful in 8m39s
ci / rust (push) Successful in 4m53s
deb / build-publish (push) Successful in 2m58s
decky / build-publish (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m50s
apple / screenshots (push) Successful in 5m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m9s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m52s
- 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>
113 lines
4.2 KiB
Swift
113 lines
4.2 KiB
Swift
// 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 20–60 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
|
||
}
|