diff --git a/apps/ios/GoBridge/share.go b/apps/ios/GoBridge/share.go index 2f23e13..9093e07 100644 --- a/apps/ios/GoBridge/share.go +++ b/apps/ios/GoBridge/share.go @@ -11,10 +11,13 @@ package share import ( "encoding/json" "fmt" + "net" "os" "path/filepath" "sync" + "time" + "github.com/tzgyn/share/src/comm" "github.com/tzgyn/share/src/swarm" "github.com/tzgyn/share/src/utils" ) @@ -75,6 +78,97 @@ func RelayDefault() string { 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 // 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 diff --git a/apps/ios/README.md b/apps/ios/README.md index cb28f17..ccee53b 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -97,6 +97,9 @@ xcrun devicectl device copy from --device \ - 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 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 diff --git a/apps/ios/Share/AppModel.swift b/apps/ios/Share/AppModel.swift index 7e9f4f4..9c256c1 100644 --- a/apps/ios/Share/AppModel.swift +++ b/apps/ios/Share/AppModel.swift @@ -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? + private var relayCheckTask: Task? + 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) 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() { diff --git a/apps/ios/Share/Models.swift b/apps/ios/Share/Models.swift index 3492319..83d00cf 100644 --- a/apps/ios/Share/Models.swift +++ b/apps/ios/Share/Models.swift @@ -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. diff --git a/apps/ios/Share/SetupView.swift b/apps/ios/Share/SetupView.swift index cda9c09..2ad85ca 100644 --- a/apps/ios/Share/SetupView.swift +++ b/apps/ios/Share/SetupView.swift @@ -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 diff --git a/src/tcp/tcp.go b/src/tcp/tcp.go index 8e8ed4f..0939b9a 100644 --- a/src/tcp/tcp.go +++ b/src/tcp/tcp.go @@ -43,8 +43,12 @@ type PeerInfo struct { // (used here as an arbitrary pairing key, not an actual room) get piped // together — the relay just forwards raw bytes between them from that // 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 string `json:"type"` // "join", "peers", or "pipe" + Type string `json:"type"` // "join", "peers", "pipe", "ping", or "pong" Room string `json:"room,omitempty"` Peer PeerInfo `json:"peer,omitempty"` Peers []PeerInfo `json:"peers,omitempty"` @@ -229,6 +233,15 @@ func (s *server) handleConnection(connection net.Conn) { return } 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: log.Warnf("client %s sent an unknown control message type %q", connection.RemoteAddr().String(), msg.Type) connection.Close()