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:
@@ -11,10 +11,13 @@ package share
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tzgyn/share/src/comm"
|
||||||
"github.com/tzgyn/share/src/swarm"
|
"github.com/tzgyn/share/src/swarm"
|
||||||
"github.com/tzgyn/share/src/utils"
|
"github.com/tzgyn/share/src/utils"
|
||||||
)
|
)
|
||||||
@@ -75,6 +78,97 @@ func RelayDefault() string {
|
|||||||
return DefaultRelay
|
return DefaultRelay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// relayStatus is the JSON result of CheckRelay. Swift shows it as a small
|
||||||
|
// "backend reachable" indicator in the Relay card.
|
||||||
|
type relayStatus struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Relay string `json:"relay"`
|
||||||
|
LatencyMs int64 `json:"latencyMs"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckRelay probes the configured relay with the protocol's stateless
|
||||||
|
// ping/pong exchange. A plain TCP connect would only prove that something
|
||||||
|
// is listening on the port; this also proves the service actually speaks
|
||||||
|
// the share relay protocol.
|
||||||
|
//
|
||||||
|
// The UI calls this in the background. The five-second dial and read
|
||||||
|
// timeouts keep a dead backend from leaving the indicator spinning.
|
||||||
|
func CheckRelay(address string) string {
|
||||||
|
relay := normalizeRelay(address)
|
||||||
|
result := relayStatus{Relay: relay}
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
c, err := comm.NewConnection(relay, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return marshalRelayStatus(result)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if err := c.Send([]byte(`{"type":"ping"}`)); err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return marshalRelayStatus(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// comm's normal read deadline is measured in hours. Run the read in a
|
||||||
|
// goroutine so a relay that accepts but never answers cannot leave the
|
||||||
|
// UI spinning; the deferred Close below unblocks the goroutine.
|
||||||
|
type readResult struct {
|
||||||
|
data []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
read := make(chan readResult, 1)
|
||||||
|
go func() {
|
||||||
|
data, err := c.Receive()
|
||||||
|
read <- readResult{data: data, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var response []byte
|
||||||
|
select {
|
||||||
|
case r := <-read:
|
||||||
|
if r.err != nil {
|
||||||
|
result.Error = r.err.Error()
|
||||||
|
return marshalRelayStatus(result)
|
||||||
|
}
|
||||||
|
response = r.data
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
result.Error = "timed out waiting for relay response"
|
||||||
|
return marshalRelayStatus(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pong struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response, &pong); err != nil || pong.Type != "pong" {
|
||||||
|
result.Error = "relay did not answer with pong"
|
||||||
|
return marshalRelayStatus(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.OK = true
|
||||||
|
result.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
return marshalRelayStatus(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRelay(address string) string {
|
||||||
|
if address == "" {
|
||||||
|
address = DefaultRelay
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return net.JoinHostPort(address, "9009")
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalRelayStatus(result relayStatus) string {
|
||||||
|
b, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return `{"ok":false,"error":"could not encode relay status"}`
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
// Start joins one room. filesJSON is a JSON array of absolute paths inside
|
// Start joins one room. filesJSON is a JSON array of absolute paths inside
|
||||||
// the app sandbox; pass "[]" to be a pure receiver. outDir is created if
|
// the app sandbox; pass "[]" to be a pure receiver. outDir is created if
|
||||||
// needed and must also be absolute. oneTime stops the room as soon as every
|
// needed and must also be absolute. oneTime stops the room as soon as every
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ xcrun devicectl device copy from --device <device> \
|
|||||||
- The default relay is the project's own `share.tzgyn.dev:9009`; the CLI and
|
- The default relay is the project's own `share.tzgyn.dev:9009`; the CLI and
|
||||||
the iOS app both use it. For local transfers, run `share relay` on a Mac and
|
the iOS app both use it. For local transfers, run `share relay` on a Mac and
|
||||||
enter its LAN address (`192.168.x.x:9009`) under Relay in the app.
|
enter its LAN address (`192.168.x.x:9009`) under Relay in the app.
|
||||||
|
- The Relay card probes the backend with a stateless protocol ping/pong and
|
||||||
|
shows the latency or the connection error; it re-checks when the address
|
||||||
|
changes and can be refreshed manually.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ final class AppModel: ObservableObject {
|
|||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var notice: String?
|
@Published var notice: String?
|
||||||
@Published var showAdvanced = false
|
@Published var showAdvanced = false
|
||||||
|
@Published var relayCheck: RelayCheck?
|
||||||
|
@Published var isCheckingRelay = false
|
||||||
|
|
||||||
private let session: ShareSession = ShareNewSession()!
|
private let session: ShareSession = ShareNewSession()!
|
||||||
private let receivedDir: URL
|
private let receivedDir: URL
|
||||||
private let seedsDir: URL
|
private let seedsDir: URL
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
private var noticeTask: Task<Void, Never>?
|
private var noticeTask: Task<Void, Never>?
|
||||||
|
private var relayCheckTask: Task<Void, Never>?
|
||||||
|
private var relayCheckGeneration = 0
|
||||||
|
|
||||||
private enum Keys {
|
private enum Keys {
|
||||||
static let relay = "share.relay"
|
static let relay = "share.relay"
|
||||||
@@ -211,6 +215,63 @@ final class AppModel: ObservableObject {
|
|||||||
code = ShareGenerateCode()
|
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
|
// MARK: - Status polling
|
||||||
|
|
||||||
private func startPolling() {
|
private func startPolling() {
|
||||||
|
|||||||
@@ -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.
|
/// A file that has already been copied into the app's seed directory.
|
||||||
/// Holding the copied URL (rather than the security-scoped picker URL)
|
/// Holding the copied URL (rather than the security-scoped picker URL)
|
||||||
/// means the Go engine can keep reading it after the picker goes away.
|
/// means the Go engine can keep reading it after the picker goes away.
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ struct SetupView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
relayStatusRow
|
||||||
|
|
||||||
if model.showAdvanced {
|
if model.showAdvanced {
|
||||||
VStack(alignment: .leading, spacing: 9) {
|
VStack(alignment: .leading, spacing: 9) {
|
||||||
TextField("host:port", text: $model.relay)
|
TextField("host:port", text: $model.relay)
|
||||||
@@ -303,6 +305,70 @@ struct SetupView: View {
|
|||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.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
|
// MARK: - Start
|
||||||
|
|||||||
+14
-1
@@ -43,8 +43,12 @@ type PeerInfo struct {
|
|||||||
// (used here as an arbitrary pairing key, not an actual room) get piped
|
// (used here as an arbitrary pairing key, not an actual room) get piped
|
||||||
// together — the relay just forwards raw bytes between them from that
|
// together — the relay just forwards raw bytes between them from that
|
||||||
// point on, blind to whatever protocol the two peers speak over it.
|
// point on, blind to whatever protocol the two peers speak over it.
|
||||||
|
//
|
||||||
|
// "ping" is a stateless health check: the relay answers "pong" and closes
|
||||||
|
// without creating a room. The iOS app uses it to show whether the
|
||||||
|
// configured backend is reachable and actually speaks the share protocol.
|
||||||
type ControlMessage struct {
|
type ControlMessage struct {
|
||||||
Type string `json:"type"` // "join", "peers", or "pipe"
|
Type string `json:"type"` // "join", "peers", "pipe", "ping", or "pong"
|
||||||
Room string `json:"room,omitempty"`
|
Room string `json:"room,omitempty"`
|
||||||
Peer PeerInfo `json:"peer,omitempty"`
|
Peer PeerInfo `json:"peer,omitempty"`
|
||||||
Peers []PeerInfo `json:"peers,omitempty"`
|
Peers []PeerInfo `json:"peers,omitempty"`
|
||||||
@@ -229,6 +233,15 @@ func (s *server) handleConnection(connection net.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.handlePipe(c, msg.Room)
|
s.handlePipe(c, msg.Room)
|
||||||
|
case "ping":
|
||||||
|
// Stateless health check: no room, no retained state. Answering
|
||||||
|
// "pong" also proves this is a share relay rather than some other
|
||||||
|
// TCP service that happened to accept the connection.
|
||||||
|
pong, err := json.Marshal(ControlMessage{Type: "pong"})
|
||||||
|
if err == nil {
|
||||||
|
_ = c.Send(pong)
|
||||||
|
}
|
||||||
|
connection.Close()
|
||||||
default:
|
default:
|
||||||
log.Warnf("client %s sent an unknown control message type %q", connection.RemoteAddr().String(), msg.Type)
|
log.Warnf("client %s sent an unknown control message type %q", connection.RemoteAddr().String(), msg.Type)
|
||||||
connection.Close()
|
connection.Close()
|
||||||
|
|||||||
Reference in New Issue
Block a user