feat(apple): wake-until-up overlay + host edit with MAC prefill
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
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>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user