Add a relay ping and backend status to the iOS app

The relay now answers a stateless ping with pong. The Relay card auto-checks on appear and address changes, showing latency or the connection error; this verifies the endpoint speaks the share protocol, not just that a TCP port is open.
This commit is contained in:
2026-10-27 21:59:27 +08:00
parent decaaa2de1
commit bf5d19900c
6 changed files with 248 additions and 1 deletions
+61
View File
@@ -21,12 +21,16 @@ final class AppModel: ObservableObject {
@Published var errorMessage: String?
@Published var notice: String?
@Published var showAdvanced = false
@Published var relayCheck: RelayCheck?
@Published var isCheckingRelay = false
private let session: ShareSession = ShareNewSession()!
private let receivedDir: URL
private let seedsDir: URL
private var pollTimer: Timer?
private var noticeTask: Task<Void, Never>?
private var relayCheckTask: Task<Void, Never>?
private var relayCheckGeneration = 0
private enum Keys {
static let relay = "share.relay"
@@ -211,6 +215,63 @@ final class AppModel: ObservableObject {
code = ShareGenerateCode()
}
// MARK: - Relay reachability
/// Debounced automatic check used while the Relay card is visible. The
/// short delay keeps every keystroke in the relay field from opening a
/// socket.
func scheduleRelayCheck() {
relayCheckTask?.cancel()
relayCheckGeneration += 1
let generation = relayCheckGeneration
relayCheck = nil
relayCheckTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled, let self else { return }
await self.performRelayCheck(generation: generation)
}
}
/// Immediate check, used by the refresh button.
func checkRelay() {
relayCheckTask?.cancel()
relayCheckGeneration += 1
let generation = relayCheckGeneration
relayCheckTask = Task { [weak self] in
guard let self else { return }
await self.performRelayCheck(generation: generation)
}
}
private func performRelayCheck(generation: Int) async {
isCheckingRelay = true
let relay = relay
// ShareCheckRelay performs a dial plus the protocol ping/pong. Keep
// that off the main thread; the Go function has its own 5s timeouts.
let json = await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in
DispatchQueue.global(qos: .utility).async {
continuation.resume(returning: ShareCheckRelay(relay))
}
}
guard generation == relayCheckGeneration else { return }
isCheckingRelay = false
guard let data = json.data(using: .utf8),
let decoded = try? JSONDecoder().decode(RelayCheck.self, from: data) else {
relayCheck = RelayCheck(
ok: false,
relay: relay.isEmpty ? ShareRelayDefault() : relay,
latencyMs: 0,
error: "Could not read the relay status."
)
return
}
relayCheck = decoded
}
// MARK: - Status polling
private func startPolling() {
+10
View File
@@ -66,6 +66,16 @@ struct RoomStatus: Codable {
}
}
/// Result of the relay's stateless ping/pong probe. `ok` is true only when
/// the configured address accepted a connection and answered with pong,
/// not merely when the TCP port was open.
struct RelayCheck: Codable, Equatable {
let ok: Bool
let relay: String
let latencyMs: Int64
let error: String?
}
/// A file that has already been copied into the app's seed directory.
/// Holding the copied URL (rather than the security-scoped picker URL)
/// means the Go engine can keep reading it after the picker goes away.
+66
View File
@@ -267,6 +267,8 @@ struct SetupView: View {
}
.buttonStyle(.plain)
relayStatusRow
if model.showAdvanced {
VStack(alignment: .leading, spacing: 9) {
TextField("host:port", text: $model.relay)
@@ -303,6 +305,70 @@ struct SetupView: View {
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.task { model.scheduleRelayCheck() }
.onChange(of: model.relay) { _ in model.scheduleRelayCheck() }
}
private var relayStatusRow: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 7) {
if model.isCheckingRelay {
ProgressView()
.controlSize(.mini)
.tint(Theme.blue)
} else {
Circle()
.fill(relayStatusColor)
.frame(width: 7, height: 7)
}
Text(relayStatusText)
.font(.system(size: 11.5, weight: .medium, design: .rounded))
.foregroundStyle(relayStatusColor)
.lineLimit(1)
Spacer(minLength: 8)
Button {
model.checkRelay()
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.secondaryText)
.frame(width: 28, height: 28)
.background(Color.white.opacity(0.05), in: Circle())
}
.buttonStyle(.plain)
.disabled(model.isCheckingRelay)
.accessibilityLabel("Check backend connectivity")
}
if let error = model.relayCheck?.error,
!error.isEmpty,
model.relayCheck?.ok == false {
Text(error)
.font(.system(size: 10, design: .rounded))
.foregroundStyle(Theme.danger.opacity(0.85))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.top, 10)
}
private var relayStatusColor: Color {
if model.isCheckingRelay { return Theme.blue }
guard let check = model.relayCheck else { return Theme.tertiaryText }
return check.ok ? Theme.mint : Theme.danger
}
private var relayStatusText: String {
if model.isCheckingRelay { return "Checking backend…" }
guard let check = model.relayCheck else { return "Backend status unknown" }
if check.ok {
return "Backend reachable · \(check.latencyMs) ms"
}
return "Backend unreachable"
}
// MARK: - Start