diff --git a/SETUP.md b/SETUP.md index d3983f91..643b1024 100644 --- a/SETUP.md +++ b/SETUP.md @@ -236,7 +236,7 @@ try fewer words. If your problem isn't here, see [Known issues](https://eternalm | **Couldn't get its address. Type the Address shown on the PC.** under a found PC | The iPad saw the PC's name but not its address. | Type the **Address** shown in the PC window, then tap **Connect to PC**. | | **Your PC didn't answer** | Nothing came back from the PC. It may be closed, asleep, on another network, or blocked by a firewall. | Check that EternalMonitor is open on the PC and says **Ready for your iPad**, that the PC is awake, and that both are on the same Wi-Fi, not a guest network. If all of that is right, Windows Firewall or security software is probably blocking EternalMonitor: run EternalMonitor-Setup.exe again, or use a USB cable. Then tap **Try again**. | | **Your PC didn't answer over the cable** | The cable is connected, but EternalMonitor on the PC didn't reply through it. | Check that EternalMonitor and Apple Devices are both open on the PC, then unplug the cable and plug it in again. | -| **Your PC answered, but no picture arrived** | The connection worked, but the PC couldn't send its screen. | Look at EternalMonitor on the PC and follow any message it shows. Click **Restart** there, then tap **Try again** on the iPad. | +| **Your PC answered, but no picture arrived** | The connection worked, but the PC's second screen didn't start, or the PC couldn't send its screen. | On the PC, choose **Mirror my screen** (**Main display (mirror)** in version 0.3.1), then tap **Try again** on the iPad. If the PC already mirrors, click **Restart** there first. After two attempts in a row end this way, the iPad stops reconnecting by itself until you tap **Try again**. | | **That looks like the pairing code, not the PC's address** | A six-digit number was typed where the address goes. | Type the **Address** from the PC window, for example 192.168.1.20. The iPad asks for the code after it connects. | | **That looks like your router's address, not your PC's** | Nothing answered at an address ending in .1, which is usually the Wi-Fi router. | Type the **Address** shown in the PC window. | | **That address doesn't look right** | The iPad couldn't find anything by that name. | Type the **Address** exactly as the PC window shows it. | @@ -317,6 +317,7 @@ or an email to [hello@sideband.studio](mailto:hello@sideband.studio). Say what you tried, what you saw, your iPad model, and whether you used Wi-Fi or USB. On iPad, open **Settings → Support → Diagnostics**, then **Export diagnostics** for a text file or **Copy diagnostics**. Include both reports when possible. +If you can't attach the report, copy the **Support ID** from the same screen into your message. Reports include Windows build, CPU, GPU and driver details; iPad hardware, iPadOS and app build; encoder/decoder setup, connection events and periodic diff --git a/ios/EternalMonitor/App/ConnectionManager.swift b/ios/EternalMonitor/App/ConnectionManager.swift index ac9a5200..88032142 100644 --- a/ios/EternalMonitor/App/ConnectionManager.swift +++ b/ios/EternalMonitor/App/ConnectionManager.swift @@ -133,6 +133,10 @@ final class ConnectionManager: ObservableObject { private var usbListener: USBListener? private var isForeground = true private var usbPaused = false + /// The cable stopped after attempts without a picture. Unlike a manual + /// Disconnect, coming back to the app keeps it stopped until Try again + /// or Connect USB. + private var usbStoppedForNoPicture = false private let currentAttempt = OSAllocatedUnfairLock(initialState: nil) private var frameAssembler: FrameAssembler? private var controlChannel: ControlChannel? @@ -149,6 +153,13 @@ final class ConnectionManager: ObservableObject { // Whether we've already granted the one-time timeout extension that fires when datagrams // start arriving (so a slow/jittery network gets a fresh window to finish reassembly+decode). private var didExtendTimeout = false + /// No-picture timeouts in a row at one PC; at the limit, attempts that + /// start by themselves stop. + private var noPictureStreak = NoPictureStreak() + /// System uptime when the current attempt started, and when its PC + /// accepted the session, for the support log's timings. + private var attemptStartedAt: TimeInterval = 0 + private var sessionAcceptedAt: TimeInterval? let frameSlot = FrameSlot() /// Lets hot-path closures reach the CURRENT control channel without @@ -232,6 +243,8 @@ final class ConnectionManager: ObservableObject { } func resumeUSBConnections() { + noPictureStreak.restartCount() + usbStoppedForNoPicture = false usbPaused = false refreshUSBAvailability() } @@ -345,6 +358,8 @@ final class ConnectionManager: ObservableObject { prevCounters = FrameAssembler.Counters() diagnostics.removeAll() didExtendTimeout = false + attemptStartedAt = ProcessInfo.processInfo.systemUptime + sessionAcceptedAt = nil videoSize = .zero let drawing = DrawingProfile(enabled: UserDefaults.standard.bool(forKey: "drawingMode"), isUSB: isUSB) sessionDrawingMode = drawing.enabled @@ -392,8 +407,9 @@ final class ConnectionManager: ObservableObject { self.debugState.decodedFrames += 1 self.debugState.lastDecodedTimestampUs = ts if self.debugState.decodedFrames == 1 { - self.record(.info, "decode", "First frame decoded successfully") + self.record(.info, "decode", "First frame decoded successfully" + self.attemptTimings) self.timeoutTask?.cancel() + self.noPictureStreak = NoPictureStreak() self.state = .connected self.streamingSince = Date() // A reconnect attempt that streams ends its cycle. @@ -503,8 +519,12 @@ final class ConnectionManager: ObservableObject { assembler?.repairEnabled = !isUSB && info.hostCaps & HelloAck.hostCapNack != 0 assembler?.framePeriodUs = 1_000_000 / UInt64(max(1, info.streamConfig.fps)) receiver?.setAcceptedSessionId(info.sessionId) + let acceptedAt = ProcessInfo.processInfo.systemUptime Task { @MainActor in guard let self, self.currentAttempt.withLock({ $0 == connectionID }) else { return } + self.sessionAcceptedAt = acceptedAt + self.noPictureStreak.answered(by: info.hostName, pairingToken: info.authToken, + at: isUSB ? nil : "\(normalizedHost):\(port)") self.livenessTimeoutUs = UInt64(info.livenessTimeoutMs) * 1000 self.hostInfo = info if self.awaitingPairing { @@ -695,6 +715,7 @@ final class ConnectionManager: ObservableObject { // connectionID, so the close FramedLink sends next is ignored // instead of relabelling this as an unplugged cable. self.usbStatus = "USB: waiting for a cable" + if self.stopsAfterClosingWithoutPicture() { return } self.beginReconnect(.cableUnplugged, failure: failure) } } @@ -703,6 +724,7 @@ final class ConnectionManager: ObservableObject { Task { @MainActor in guard let self, self.currentAttempt.withLock({ $0 == connectionID }) else { return } if isUSB { self.usbStatus = "USB: waiting for a cable" } + if self.stopsAfterClosingWithoutPicture() { return } // An ordinary close is the cable coming out; a close after a // classified failure keeps that failure's advice. self.beginReconnect(.cableUnplugged, failure: self.linkFailure) @@ -902,6 +924,17 @@ final class ConnectionManager: ObservableObject { reconnectAttempt = 0 } + /// The session and how long the current attempt has taken, appended to + /// the support log's first-frame and timeout lines. + private var attemptTimings: String { + let now = ProcessInfo.processInfo.systemUptime + var text = "" + if let session = hostInfo?.sessionId { text += " session_id=\(session)" } + text += " ms_since_start=\(Int((now - attemptStartedAt) * 1000))" + if let accepted = sessionAcceptedAt { text += " ms_since_accept=\(Int((now - accepted) * 1000))" } + return text + } + /// (Re)arm the connect-timeout watchdog. Cancels any existing timer and starts a fresh one; /// if we're still `.connecting` when it fires, surface a diagnostic and disconnect. private func armConnectTimeout(seconds: Double) { @@ -911,16 +944,52 @@ final class ConnectionManager: ObservableObject { if !Task.isCancelled && self.state == .connecting { let debug = self.debugState let handshakeDone = self.hostInfo != nil - self.record(.error, "timeout", debug.timeoutSummary(handshakeDone: handshakeDone)) - self.problem = .timeout( + let isUSB = self.transportMode == "USB" + self.record(.error, "timeout", debug.timeoutSummary(handshakeDone: handshakeDone) + self.attemptTimings) + // Only an attempt the PC answered counts. + if handshakeDone { self.noPictureStreak.endedWithoutPicture() } + self.problem = self.noPictureStreak.shown(.timeout( listenerReady: debug.listenerReadyPort != nil, helloAttempts: debug.helloAttempts, handshakeDone: handshakeDone, datagramsReceived: debug.datagramsReceived, - isUSB: self.transportMode == "USB", host: debug.host) - self.disconnect() + isUSB: isUSB, host: debug.host), at: isUSB ? nil : "\(debug.host):\(debug.port)") + // The app gave up, not the user: the PC's log tells them apart. + self.disconnect(reason: .error) + if handshakeDone && self.noPictureStreak.stopsReconnecting { self.stopAutomaticAttempts(isUSB: isUSB) } } } } + /// The PC answered this attempt and closed the connection before a + /// picture, as a PC on 0.3.1 does over the cable each time its second + /// screen restarts. That counts like a no-picture timeout; at the limit + /// it ends the attempt here and returns true, instead of reconnecting. + private func stopsAfterClosingWithoutPicture() -> Bool { + guard state == .connecting, hostInfo != nil else { return false } + noPictureStreak.endedWithoutPicture() + guard noPictureStreak.stopsReconnecting else { return false } + let isUSB = transportMode == "USB" + record(.warning, "ctrl", "Connection closed by the PC before a picture" + attemptTimings) + disconnect(reason: .error) + problem = .noPicture + stopAutomaticAttempts(isUSB: isUSB) + return true + } + + /// The PC answers but never sends a picture: stop the attempts that + /// start by themselves, the reconnect cycle and the cable the PC + /// connects again, and leave the problem card with Try again up. + private func stopAutomaticAttempts(isUSB: Bool) { + record(.warning, "connection", "Stopped reconnecting after \(noPictureStreak.timeouts) no-picture timeouts in a row") + endReconnectCycle() + if isUSB { + usbPaused = true + usbStoppedForNoPicture = true + usbListener?.stop() + usbListener = nil + usbStatus = "USB: disconnected" + } + } + func cancel() { if mediaLink?.isUSB == true { usbPaused = true @@ -940,6 +1009,7 @@ final class ConnectionManager: ObservableObject { guard state == .disconnected else { return } let cable = transportMode == "USB" || problem == .usbFailed endReconnectCycle() + noPictureStreak.restartCount() problem = nil connectionError = nil if cable { @@ -1028,7 +1098,7 @@ final class ConnectionManager: ObservableObject { func handleAppForegrounded() { endBackgroundGoodbye() isForeground = true - usbPaused = false + usbPaused = usbStoppedForNoPicture refreshUSBAvailability() guard resumeOnForeground else { return } resumeOnForeground = false diff --git a/ios/EternalMonitor/App/ConnectionProblem.swift b/ios/EternalMonitor/App/ConnectionProblem.swift index c7560e5c..2b5d1e4c 100644 --- a/ios/EternalMonitor/App/ConnectionProblem.swift +++ b/ios/EternalMonitor/App/ConnectionProblem.swift @@ -88,8 +88,8 @@ enum ConnectionProblem: Equatable { case .noReply(usb: true): return "Check that EternalMonitor and Apple Devices are both open on the PC, then unplug the cable and plug it in again." case .noPicture: - return "EternalMonitor on the PC couldn't send its screen. Look there for a message and follow it, click Restart, then try again. " - + "If it keeps happening, send us Settings, Diagnostics from this iPad and Performance, Copy logs from the PC." + return "The PC's second screen didn't start. On the PC, choose Mirror my screen (called Main display (mirror) in version 0.3.1), " + + "then try again. If it already mirrors, click Restart on the PC first." case .receiverNotReady: return "Close EternalMonitor on this iPad and open it again." case .badAddress(let host): @@ -246,6 +246,78 @@ enum ConnectionProblem: Equatable { private static let offline: Set = [.ENETDOWN] } +/// Connect attempts in a row that one PC answered and that ended without a +/// picture. A PC on 0.3.1 or 0.3.2 in Second screen mode answers, then can +/// take longer than this iPad waits to send its first picture, and every new +/// attempt restarts its second screen; on 0.3.1 that restart also closes the +/// cable a few seconds in. Over the cable the PC connects again by itself a +/// second later, so without a limit the attempts never end and the PC's +/// screen blinks with each one. +struct NoPictureStreak: Equatable { + /// Attempts that start by themselves stop at this many attempts without + /// a picture. One more attempt lets a PC that was only slow the first + /// time (a busy PC, a second screen that starts once) show its picture; + /// a second one means it fails the same way every time. + static let limit = 2 + + /// A PC by its pairing token, which it keeps across restarts and gives + /// over Wi-Fi and the cable alike. With pairing off it gives none, so + /// the name it gave, or its address if it gave none either. + enum PC: Equatable { + case paired(Data) + case named(String) + } + + /// The PC that answered. + private(set) var pc: PC? + /// Where that PC last answered over Wi-Fi, as "address:port". The cable + /// doesn't say where the PC is on Wi-Fi, so an answer over it keeps this. + private(set) var address: String? + private(set) var timeouts = 0 + /// That PC answered and sent no picture in this series. + private(set) var answeredWithoutPicture = false + + var stopsReconnecting: Bool { timeouts >= Self.limit } + + /// The PC `name` with pairing token `pairingToken` answered at `address` + /// (nil over the cable). Another PC starts a new series. + mutating func answered(by name: String, pairingToken: Data, at address: String?) { + let pc: PC = PairingStore.valid(pairingToken) ? .paired(pairingToken) + : .named(name.isEmpty ? address ?? "USB" : name) + if pc != self.pc { + self = NoPictureStreak() + self.pc = pc + } + if let address { self.address = address } + } + + /// The attempt the PC answered ended without a picture: the timeout + /// fired, or the PC closed the connection first. + mutating func endedWithoutPicture() { + guard pc != nil else { return } + timeouts += 1 + answeredWithoutPicture = true + } + + /// The problem to show for a timeout at `address` (nil over the cable). + /// A PC that answered there earlier in the series and is silent now is + /// most likely restarting EternalMonitor, so it keeps the advice about + /// the picture. + func shown(_ problem: ConnectionProblem, at address: String?) -> ConnectionProblem { + switch problem { + case .noReply(usb: false), .routerAddress: + return answeredWithoutPicture && address != nil && address == self.address ? .noPicture : problem + default: + return problem + } + } + + /// Try again: the user starts a new count. The PC still answered before. + mutating func restartCount() { + timeouts = 0 + } +} + /// What the USB cable card says for each listener status. enum USBStatusText { static func describe(_ status: String) -> String { diff --git a/ios/EternalMonitor/App/DiagnosticsView.swift b/ios/EternalMonitor/App/DiagnosticsView.swift index ec4c8027..38b51fce 100644 --- a/ios/EternalMonitor/App/DiagnosticsView.swift +++ b/ios/EternalMonitor/App/DiagnosticsView.swift @@ -18,12 +18,33 @@ struct DiagnosticsView: View { @State private var preparing = false @State private var error: String? @State private var copied = false + @State private var supportIDCopied = false + private let supportID = DiagnosticsUpload.shared.installationID @AppStorage(DiagnosticsUpload.enabledKey) private var uploadEnabled = true @State private var uploadStatus = "Checking automatic upload" var body: some View { Form { + Section { + LabeledContent("Support ID") { + Text(supportID) + .font(.footnote.monospaced()) + .textSelection(.enabled) + } + .accessibilityIdentifier("diagnostics.supportID") + Button { + UIPasteboard.general.string = supportID + supportIDCopied = true + } label: { + Label(supportIDCopied ? "Support ID copied" : "Copy Support ID", systemImage: "doc.on.doc") + } + .accessibilityIdentifier("diagnostics.copySupportID") + } footer: { + Text("Include this ID when you write to us, so we can find the diagnostics this iPad shared.") + } + .listRowBackground(Theme.surfaceRaised) + Section { Toggle("Automatically share diagnostics", isOn: $uploadEnabled) .accessibilityIdentifier("diagnostics.automaticUpload") diff --git a/ios/EternalMonitor/Core/DiagnosticsUpload.swift b/ios/EternalMonitor/Core/DiagnosticsUpload.swift index 8c2f9edf..22ab2ab2 100644 --- a/ios/EternalMonitor/Core/DiagnosticsUpload.swift +++ b/ios/EternalMonitor/Core/DiagnosticsUpload.swift @@ -50,7 +50,8 @@ final class DiagnosticsUpload: NSObject, URLSessionTaskDelegate, @unchecked Send private var nextAttempt = Date.distantPast private var backoff = 5.0 private var statusText = "Automatic upload is not configured" - private let installationID: String + /// Shown to the user as the Support ID: support finds this iPad's uploaded rows by it. + let installationID: String private let launchID = UUID().uuidString private var dropped = 0 private let lossLock = NSLock() diff --git a/ios/EternalMonitor/Core/Feedback.swift b/ios/EternalMonitor/Core/Feedback.swift index 5f5bb093..8e30a4fa 100644 --- a/ios/EternalMonitor/Core/Feedback.swift +++ b/ios/EternalMonitor/Core/Feedback.swift @@ -32,6 +32,8 @@ enum Feedback { var pcVersion: String? /// "USB" or "Wi-Fi" while connected. var connection: String? + /// The diagnostics upload ID, which finds this iPad's rows. + var supportID: String? = nil @MainActor static func current(pcVersion: String?, connection: String?) -> Context { let info = Bundle.main.infoDictionary @@ -41,7 +43,8 @@ enum Feedback { systemVersion: UIDevice.current.systemVersion, model: modelIdentifier(), pcVersion: pcVersion, - connection: connection + connection: connection, + supportID: DiagnosticsUpload.shared.installationID ) } @@ -73,6 +76,9 @@ enum Feedback { /// report attached, the email says so first. static func body(_ kind: Kind, _ context: Context, attachesDiagnostics: Bool = false) -> String { var lines: [String] = [] + if let supportID = context.supportID { + lines.append("Support ID: \(supportID)") + } if case .problem(let title) = kind { lines.append("Problem: \(title)") } diff --git a/ios/EternalMonitor/Core/PairingStore.swift b/ios/EternalMonitor/Core/PairingStore.swift index b864da22..22e44ac0 100644 --- a/ios/EternalMonitor/Core/PairingStore.swift +++ b/ios/EternalMonitor/Core/PairingStore.swift @@ -69,7 +69,7 @@ final class PairingStore: ObservableObject { return "\(host.contains(":") ? "[\(host)]" : host):\(port)" } - static func valid(_ token: Data) -> Bool { token.count == 16 && token.contains(where: { $0 != 0 }) } + nonisolated static func valid(_ token: Data) -> Bool { token.count == 16 && token.contains(where: { $0 != 0 }) } func token(address: String) throws -> Data? { try storage.records().first { $0.account.hasSuffix("|\(address)") && Self.valid($0.token) }?.token diff --git a/ios/EternalMonitor/Core/SupportDiagnostics.swift b/ios/EternalMonitor/Core/SupportDiagnostics.swift index 24dd7bb8..32980a0b 100644 --- a/ios/EternalMonitor/Core/SupportDiagnostics.swift +++ b/ios/EternalMonitor/Core/SupportDiagnostics.swift @@ -99,7 +99,7 @@ final class SupportDiagnostics: @unchecked Sendable { return await withCheckedContinuation { continuation in queue.async { self.lossLock.lock(); let dropped = self.dropped; self.lossLock.unlock() - var report = "EternalMonitor iPad support report, schema 1\nLaunch: \(self.launchID)\nAutomatic upload: \(uploadStatus)\nOutput queue dropped events: \(dropped)\nStorage error: \(self.storageError ?? "none")\nLogs may contain PC/device names and local network addresses.\n\n--- Startup evidence ---\n\(self.startup.joined())" + var report = "EternalMonitor iPad support report, schema 1\nSupport ID: \(DiagnosticsUpload.shared.installationID)\nLaunch: \(self.launchID)\nAutomatic upload: \(uploadStatus)\nOutput queue dropped events: \(dropped)\nStorage error: \(self.storageError ?? "none")\nLogs may contain PC/device names and local network addresses.\n\n--- Startup evidence ---\n\(self.startup.joined())" for suffix in [".2", ".1", ""] { if let text = try? String(contentsOf: self.url(suffix), encoding: .utf8) { report += "\n--- Log\(suffix) ---\n\(text)" diff --git a/ios/EternalMonitorTests/ConnectionProblemTests.swift b/ios/EternalMonitorTests/ConnectionProblemTests.swift index 1dcc2970..5e48dff6 100644 --- a/ios/EternalMonitorTests/ConnectionProblemTests.swift +++ b/ios/EternalMonitorTests/ConnectionProblemTests.swift @@ -14,6 +14,102 @@ final class ConnectionProblemTests: XCTestCase { XCTAssertEqual(ConnectionProblem.timeout(listenerReady: true, helloAttempts: 1, handshakeDone: true, datagramsReceived: 0, isUSB: true), .noPicture) } + /// PCs on 0.3.1 and 0.3.2 answer but send no picture in Second screen + /// mode. The card says to mirror, naming the choice as both versions do. + func testNoPictureSaysToMirrorTheScreen() { + let detail = ConnectionProblem.noPicture.detail + XCTAssertTrue(detail.hasPrefix("The PC's second screen didn't start."), detail) + XCTAssertTrue(detail.contains("choose Mirror my screen") && detail.contains("Main display (mirror)"), detail) + XCTAssertTrue(detail.contains("try again"), detail) + XCTAssertEqual(ConnectionProblem.noPicture.actions, [.retry]) + XCTAssertTrue(ConnectionProblem.noPicture.canReport) + } + + /// Pairing tokens two PCs give when pairing is on, and the token a PC + /// gives with pairing off. + private let desk = Data(17...32), studio = Data(33...48), unpaired = Data(count: 16) + + func testNoPictureAttemptsCountPerPCUpToTheLimit() { + var streak = NoPictureStreak() + XCTAssertEqual(NoPictureStreak.limit, 2) + // Nothing answered: nothing to count. + streak.endedWithoutPicture() + XCTAssertEqual(streak.timeouts, 0) + streak.answered(by: "Desk PC", pairingToken: desk, at: "192.168.1.20:9876") + streak.endedWithoutPicture() + XCTAssertFalse(streak.stopsReconnecting, "one attempt without a picture gets another") + streak.answered(by: "Desk PC", pairingToken: desk, at: "192.168.1.20:9876") + streak.endedWithoutPicture() + XCTAssertTrue(streak.stopsReconnecting) + // Try again starts a new count. + streak.restartCount() + XCTAssertFalse(streak.stopsReconnecting) + streak.endedWithoutPicture() + XCTAssertFalse(streak.stopsReconnecting) + } + + /// The count belongs to the PC by its pairing token, not to its name, + /// the address or the cable: two PCs with one name count separately, + /// and one PC renamed, at a new address or on the cable counts on. + func testNoPictureAttemptsCountByThePCThatAnswered() { + var streak = NoPictureStreak() + streak.answered(by: "PC", pairingToken: desk, at: nil) + streak.endedWithoutPicture() + streak.answered(by: "PC", pairingToken: studio, at: nil) + XCTAssertEqual(streak.timeouts, 0, "another PC with the same name starts a new count") + streak.endedWithoutPicture() + XCTAssertFalse(streak.stopsReconnecting) + streak.answered(by: "Studio PC", pairingToken: studio, at: "192.168.1.40:9876") + streak.endedWithoutPicture() + XCTAssertTrue(streak.stopsReconnecting, "the same PC, renamed and over Wi-Fi, counts on") + // With pairing off a PC counts by its name, or its address without one. + var named = NoPictureStreak() + named.answered(by: "Desk PC", pairingToken: unpaired, at: nil) + named.endedWithoutPicture() + named.answered(by: "Desk PC", pairingToken: unpaired, at: "192.168.1.20:9876") + XCTAssertEqual(named.timeouts, 1) + named.answered(by: "Studio PC", pairingToken: unpaired, at: "192.168.1.20:9876") + XCTAssertEqual(named.timeouts, 0) + var unnamed = NoPictureStreak() + unnamed.answered(by: "", pairingToken: unpaired, at: "192.168.1.20:9876") + unnamed.endedWithoutPicture() + unnamed.answered(by: "", pairingToken: unpaired, at: "192.168.1.30:9876") + XCTAssertEqual(unnamed.timeouts, 0) + } + + func testAPCThatAnsweredInTheSeriesKeepsTheNoPictureMessage() { + var streak = NoPictureStreak() + let address = "192.168.1.1:9876" + // Nothing answered yet: the usual messages. + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noReply(usb: false)) + XCTAssertEqual(streak.shown(.routerAddress, at: address), .routerAddress) + streak.answered(by: "Desk PC", pairingToken: desk, at: address) + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noReply(usb: false), + "a PC that answered but hasn't failed yet") + streak.endedWithoutPicture() + // Silent while EternalMonitor restarts on the PC that answered. + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noPicture) + XCTAssertEqual(streak.shown(.routerAddress, at: address), .noPicture) + // Try again keeps it: the PC still answered before. + streak.restartCount() + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noPicture) + // Something the user must fix still shows as itself. + XCTAssertEqual(streak.shown(.receiverNotReady, at: address), .receiverNotReady) + // Another address never answered. + XCTAssertEqual(streak.shown(.noReply(usb: false), at: "192.168.1.30:9876"), .noReply(usb: false)) + // The cable doesn't say which PC is on it, so a silent one keeps its own advice. + XCTAssertEqual(streak.shown(.noReply(usb: true), at: nil), .noReply(usb: true)) + streak.answered(by: "Desk PC", pairingToken: desk, at: nil) + streak.endedWithoutPicture() + XCTAssertEqual(streak.shown(.noReply(usb: true), at: nil), .noReply(usb: true)) + // The same PC answering over the cable keeps where it answered over Wi-Fi. + XCTAssertEqual(streak.address, address) + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noPicture) + // Another PC answering ends the series. + streak.answered(by: "Desk PC", pairingToken: studio, at: address) + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noReply(usb: false)) + } + func testAPairingCodeTypedAsTheAddressIsCaughtBeforeConnecting() { for typed in ["390756", "390 756", " 390756 "] { XCTAssertEqual(ConnectionProblem.typedAddress(typed), .pairingCodeAsAddress, typed) diff --git a/ios/EternalMonitorTests/FakeUDPHost.swift b/ios/EternalMonitorTests/FakeUDPHost.swift index b4112e63..9a79174a 100644 --- a/ios/EternalMonitorTests/FakeUDPHost.swift +++ b/ios/EternalMonitorTests/FakeUDPHost.swift @@ -52,17 +52,23 @@ final class FakeUDPHost: @unchecked Sendable { guard let (data, from, length) = receive() else { continue } guard case .hello2(let hello) = Wire.parseControl(data)?.message, answered.insert(hello.clientNonce).inserted else { continue } - let ack = Wire.encodeControl(sessionId: 0, msgSeq: 1, message: .helloAck(HelloAck( - status: status, acceptedVersion: acceptedVersion, clientNonce: hello.clientNonce, sessionId: status == .ok ? 41 : 0, - heartbeatIntervalMs: 1000, reportIntervalMs: 500, livenessTimeoutMs: 3000, - streamConfig: StreamConfig(), hostName: "Test PC", authToken: Data(17...32), hostCaps: 0))) client = (from, length) - send(ack) + send(Self.ack(nonce: hello.clientNonce, status: status, acceptedVersion: acceptedVersion)) return true } return false } + /// The HELLO_ACK for the attempt with `nonce` from the PC `hostName` + /// with pairing token `authToken`: session 41, unless `status` refuses it. + static func ack(nonce: UInt32, status: HelloStatus = .ok, acceptedVersion: UInt8 = 2, + hostName: String = "Test PC", authToken: Data = Data(17...32)) -> Data { + Wire.encodeControl(sessionId: 0, msgSeq: 1, message: .helloAck(HelloAck( + status: status, acceptedVersion: acceptedVersion, clientNonce: nonce, sessionId: status == .ok ? 41 : 0, + heartbeatIntervalMs: 1000, reportIntervalMs: 500, livenessTimeoutMs: 3000, + streamConfig: StreamConfig(), hostName: hostName, authToken: authToken, hostCaps: 0))) + } + private func send(_ datagram: Data) { guard var (to, length) = client else { return } _ = datagram.withUnsafeBytes { bytes in diff --git a/ios/EternalMonitorTests/FeedbackTests.swift b/ios/EternalMonitorTests/FeedbackTests.swift index edc77648..252d2e32 100644 --- a/ios/EternalMonitorTests/FeedbackTests.swift +++ b/ios/EternalMonitorTests/FeedbackTests.swift @@ -39,6 +39,20 @@ final class FeedbackTests: XCTestCase { XCTAssertFalse(text.contains("Problem:"), text) } + func testTheSupportIDComesFirstInTheDetails() { + var withID = context + withID.supportID = "5A1E0C3B-7D2F-4E8A-9B6C-0F1D2E3A4B5C" + let body = Feedback.body(.problem("Your PC didn't answer"), withID) + XCTAssertTrue(body.contains("--\nSupport ID: 5A1E0C3B-7D2F-4E8A-9B6C-0F1D2E3A4B5C\nProblem: Your PC didn't answer\n"), body) + } + + @MainActor + func testTheSupportIDIsTheDiagnosticsUploadID() { + let current = Feedback.Context.current(pcVersion: nil, connection: nil) + XCTAssertEqual(current.supportID, DiagnosticsUpload.shared.installationID) + XCTAssertEqual(UUID(uuidString: current.supportID ?? "")?.uuidString, current.supportID) + } + func testAProblemCardNamesItsProblem() { let body = Feedback.body(.problem(ConnectionProblem.noReply(usb: false).title), context) XCTAssertTrue(body.contains("--\nProblem: Your PC didn't answer\nApp:"), body) diff --git a/ios/EternalMonitorTests/NoPictureTimeoutTests.swift b/ios/EternalMonitorTests/NoPictureTimeoutTests.swift new file mode 100644 index 00000000..74fc80c8 --- /dev/null +++ b/ios/EternalMonitorTests/NoPictureTimeoutTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import EternalMonitor + +/// A PC that answers but sends no picture before the connect timeout, as +/// PCs on 0.3.1 and 0.3.2 do while their second screen starts. +@MainActor +final class NoPictureTimeoutTests: XCTestCase { + private let fast = ConnectionManager.Timing(connectTimeoutSeconds: 0.5, reconnectGraceSeconds: 0.1, + reconnectBackoffSeconds: { _ in 0.05 }) + + override func setUp() async throws { + let previous = UserDefaults.standard.object(forKey: "autoReconnect") + UserDefaults.standard.set(true, forKey: "autoReconnect") + addTeardownBlock { UserDefaults.standard.set(previous, forKey: "autoReconnect") } + } + + private func until(_ description: String, seconds: Double = 10, _ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(seconds) + while !condition(), Date() < deadline { try await Task.sleep(nanoseconds: 10_000_000) } + XCTAssertTrue(condition(), description) + } + + /// One manual attempt at `host` that it answers, ending in its timeout. + private func timedOutAttempt(_ manager: ConnectionManager, at host: FakeUDPHost) async throws { + manager.connect(host: "127.0.0.1", port: host.port) + XCTAssertEqual(manager.state, .connecting) + let accepted = await Task.detached { host.acceptSession() }.value + XCTAssertTrue(accepted, "the iPad never sent HELLO2") + try await until("timed out") { manager.state == .disconnected } + } + + /// A session on `host` that streams. + private func streaming(_ host: FakeUDPHost) async throws -> ConnectionManager { + let manager = ConnectionManager(timing: ConnectionManager.Timing(connectTimeoutSeconds: 1, + reconnectGraceSeconds: 0.1, reconnectBackoffSeconds: { _ in 0.05 })) + manager.connect(host: "127.0.0.1", port: host.port) + let accepted = await Task.detached { host.acceptSession() }.value + XCTAssertTrue(accepted, "the iPad never sent HELLO2") + try await until("handshake") { manager.hostInfo != nil } + host.sendKeyframe(try FakeUDPHost.keyframe()) + try await until("streaming") { manager.state == .connected } + return manager + } + + private func logged(_ manager: ConnectionManager, _ prefix: String) -> String? { + manager.diagnostics.first { $0.message.hasPrefix(prefix) }?.message + } + + /// The app gave up, not the user: the PC hears the error reason, and the + /// support log names the session and how long the attempt waited. + func testATimeoutSaysGoodbyeWithTheErrorReason() async throws { + let host = FakeUDPHost() + let manager = ConnectionManager(timing: fast) + defer { manager.cancel() } + try await timedOutAttempt(manager, at: host) + let reasons = await Task.detached { host.goodbyes() }.value + XCTAssertEqual(reasons, Array(repeating: .error, count: ControlChannel.byeCopies)) + XCTAssertEqual(manager.problem, .noPicture) + let timeout = logged(manager, "Session accepted by 127.0.0.1:") ?? "" + XCTAssertTrue(timeout.contains("no video datagrams arrived. session_id=41 ms_since_start="), timeout) + XCTAssertTrue(timeout.contains(" ms_since_accept="), timeout) + XCTAssertNotNil(logged(manager, "Disconnect state=connecting reason=error")) + XCTAssertNil(logged(manager, "Disconnect state=connecting reason=userDisconnect")) + } + + func testTheFirstFrameLineNamesTheSessionAndTheWait() async throws { + let host = FakeUDPHost() + let manager = try await streaming(host) + defer { manager.cancel() } + let line = logged(manager, "First frame decoded successfully") ?? "" + XCTAssertTrue(line.contains(" session_id=41 ms_since_start="), line) + XCTAssertTrue(line.contains(" ms_since_accept="), line) + } + + /// The PC restarts its stream and then answers every attempt without a + /// picture: the cycle stops at the limit and leaves Try again up. Try + /// again starts a new count, so its own timeout stops nothing. + func testRepeatedNoPictureTimeoutsStopReconnectingUntilTryAgain() async throws { + let host = FakeUDPHost() + let manager = try await streaming(host) + defer { manager.cancel() } + host.sendBye(.error) + try await until("reconnecting") { manager.reconnectAttempt == 1 } + let answered = await Task.detached { (0.. Bool { + var timeout = timeval(tv_sec: 0, tv_usec: 300_000) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + var framer = EMLinkFramer(expectsPreamble: false) + var buffer = [UInt8](repeating: 0, count: 4096) + let deadline = Date().addingTimeInterval(5) + while Date() < deadline { + let count = recv(fd, &buffer, buffer.count, 0) + guard count > 0, let datagrams = try? framer.append(Data(buffer.prefix(count))) else { continue } + for datagram in datagrams { + guard case .hello2(let hello) = Wire.parseControl(datagram)?.message, + let framed = try? EMLinkFramer.encode(FakeUDPHost.ack(nonce: hello.clientNonce, hostName: name, + authToken: token)) else { continue } + return framed.withUnsafeBytes { Darwin.send(fd, $0.baseAddress, $0.count, 0) } == framed.count + } + } + return false + } + + /// The loop that blinked the PC's screen: every timeout closed the cable, + /// the PC connected it again, and the iPad took it. At the limit the + /// iPad stops taking the cable until Try again. + func testRepeatedNoPictureOverTheCablePausesItUntilTryAgain() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + // Try again must not open the app's real USB listener port. + UserDefaults.standard.set(false, forKey: "allowUSB") + var cables: [FramedLink] = [] + defer { cables.forEach { $0.stop() } } + let (manager, first) = try await connectOverCable(timing: fast) + defer { manager.cancel() } + cables.append(first) + for attempt in 1...NoPictureStreak.limit { + if attempt > 1 { + let cable = try await openCable() + cables.append(cable) + manager.acceptUSBLink(cable) + XCTAssertEqual(manager.state, .connecting, "attempt \(attempt) takes the cable") + } + let fd = peer + let accepted = await Task.detached { Self.acceptSession(on: fd) }.value + XCTAssertTrue(accepted, "attempt \(attempt) never sent HELLO2") + try await until("attempt \(attempt) times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture, "attempt \(attempt)") + } + XCTAssertEqual(manager.usbStatus, "USB: disconnected", "the cable is paused") + let refused = try await openCable() + cables.append(refused) + manager.acceptUSBLink(refused) + XCTAssertEqual(manager.state, .disconnected, "the PC's next cable connection is not taken") + XCTAssertEqual(manager.problem, .noPicture) + XCTAssertEqual(manager.problem?.actions, [.retry]) + + manager.retry() + XCTAssertNil(manager.problem) + let again = try await openCable() + cables.append(again) + manager.acceptUSBLink(again) + XCTAssertEqual(manager.state, .connecting, "Try again takes the cable again") + } + + /// The PC connects the cable again, as it does a second after each + /// close, and offers it to the iPad. + private func plugIn(_ manager: ConnectionManager) async throws -> FramedLink { + let cable = try await openCable() + manager.acceptUSBLink(cable) + return cable + } + + /// The PC `name` with pairing token `token` answers on the newest cable + /// and sends no picture. + private func answer(as name: String = "Test PC", token: Data = Data(17...32)) async { + let fd = peer + let accepted = await Task.detached { Self.acceptSession(on: fd, name: name, token: token) }.value + XCTAssertTrue(accepted, "the iPad never sent HELLO2") + } + + /// A PC on 0.3.1 restarts its stream when its second screen starts, and + /// that closed the cable a few seconds after it answered, before the + /// iPad's timeout; a second later it connected the cable again. Each of + /// those counts as a no-picture attempt, whether the cable closes or + /// fails, so the loop stops at the limit. A cable that ends before the + /// PC answers doesn't count. + func testAPCThatAnswersAndThenEndsTheCableStopsAtTheLimit() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + // Try again must not open the app's real USB listener port. + UserDefaults.standard.set(false, forKey: "allowUSB") + enableWiFiFallback(to: FakeUDPHost()) // the saved Wi-Fi PC, silent + // The cable comes back during the fallback's first wait, long before + // the connect timeout. + let timing = ConnectionManager.Timing(connectTimeoutSeconds: 5, reconnectGraceSeconds: 0.1, + reconnectBackoffSeconds: { _ in 1 }) + for fails in [false, true] { + let manager = ConnectionManager(timing: timing) + var cables: [FramedLink] = [] + defer { cables.forEach { $0.stop() }; manager.cancel() } + let ending = fails ? "fails" : "closes" + for attempt in 1...NoPictureStreak.limit { + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "unanswered cable \(attempt) is taken, \(ending)") + if fails { injectFramingFailure() } else { XCTAssertEqual(shutdown(peer, SHUT_WR), 0) } + try await until("unanswered cable \(attempt) ends") { manager.state == .disconnected } + } + for attempt in 1...NoPictureStreak.limit { + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "attempt \(attempt) takes the cable, \(ending)") + await answer() + try await until("attempt \(attempt) is accepted") { manager.hostInfo != nil } + if fails { injectFramingFailure() } else { XCTAssertEqual(shutdown(peer, SHUT_WR), 0) } + try await until("attempt \(attempt) ends") { manager.state == .disconnected } + if attempt < NoPictureStreak.limit { + try await until("one attempt without a picture falls back as before, \(ending)") { + manager.reconnectAttempt == 1 + } + } + } + XCTAssertEqual(manager.problem, .noPicture, ending) + XCTAssertEqual(manager.problem?.actions, [.retry]) + XCTAssertEqual(manager.reconnectAttempt, 0, "no fallback after the limit, \(ending)") + XCTAssertEqual(manager.usbStatus, "USB: disconnected", "the cable is paused, \(ending)") + XCTAssertNotNil(manager.diagnostics.first { $0.message.hasPrefix("Stopped reconnecting after 2 no-picture") }) + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .disconnected, "the PC's next cable connection is not taken, \(ending)") + XCTAssertEqual(manager.problem, .noPicture) + } + } + + /// Leaving the app and coming back keeps the cable stopped: only Try + /// again takes it again. + func testTheCableStopOutlastsLeavingTheApp() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + // Foregrounding must not open the app's real USB listener port. + UserDefaults.standard.set(false, forKey: "allowUSB") + let (manager, first) = try await connectOverCable(timing: fast) + var cables = [first] + defer { cables.forEach { $0.stop() }; manager.cancel() } + for attempt in 1...NoPictureStreak.limit { + if attempt > 1 { cables.append(try await plugIn(manager)) } + await answer() + try await until("attempt \(attempt) times out") { manager.state == .disconnected } + } + XCTAssertEqual(manager.usbStatus, "USB: disconnected", "the cable is paused") + manager.handleAppBackgrounded() + manager.handleAppForegrounded() + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .disconnected, "the cable stays stopped after the app comes back") + XCTAssertEqual(manager.problem, .noPicture) + XCTAssertEqual(manager.problem?.actions, [.retry]) + + manager.retry() + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "Try again takes the cable again") + } + + /// Two PCs on the cable, each without a picture once, don't add up: the + /// count belongs to the PC that answered, by its pairing token, even + /// when both PCs have the same name. A cable on which nothing answers + /// gets its own advice, since the cable doesn't say which PC is at its + /// other end, and doesn't end the count. + func testPCsOnTheCableCountSeparately() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + UserDefaults.standard.set(false, forKey: "allowUSB") + let desk = Data(17...32), studio = Data(33...48) + let (manager, first) = try await connectOverCable(timing: fast) + var cables = [first] + defer { cables.forEach { $0.stop() }; manager.cancel() } + await answer(token: desk) + try await until("the first PC times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "another PC with the same name takes the cable") + await answer(token: studio) + try await until("the second PC times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + XCTAssertNil(manager.diagnostics.first { $0.message.hasPrefix("Stopped reconnecting") }, + "one timeout at each of two PCs stops nothing") + + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "the cable is still taken") + try await until("nothing answers on the cable") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noReply(usb: true), "a silent cable keeps its own advice") + + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting) + await answer(token: studio) + try await until("the second PC times out again") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + XCTAssertEqual(manager.usbStatus, "USB: disconnected", "its second timeout stops the cable") + } + + /// The PC answered over Wi-Fi without a picture, then over the cable. + /// Silent at its Wi-Fi address afterwards, it is most likely restarting + /// EternalMonitor: the cable doesn't erase where it answered. + func testAnAnswerOverTheCableKeepsThePCsWiFiAddress() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + UserDefaults.standard.set(false, forKey: "allowUSB") + let wifi = FakeUDPHost() + let manager = ConnectionManager(timing: fast) + var cables: [FramedLink] = [] + defer { cables.forEach { $0.stop() }; manager.cancel() } + manager.connect(host: "127.0.0.1", port: wifi.port) + let accepted = await Task.detached { wifi.acceptSession() }.value + XCTAssertTrue(accepted, "the iPad never sent HELLO2 over Wi-Fi") + try await until("the Wi-Fi attempt times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "the same PC's cable is taken") + await answer() + try await until("the cable attempt times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + + manager.connect(host: "127.0.0.1", port: wifi.port) // nothing answers now + try await until("the silent Wi-Fi attempt times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture, "the PC answered at this address before") + } + // MARK: Answers from the PC during the fallback. Busy and a restart pass; // the cycle keeps retrying. diff --git a/scripts/QUICKSTART.txt b/scripts/QUICKSTART.txt index 5bb026d8..b2c76b37 100644 --- a/scripts/QUICKSTART.txt +++ b/scripts/QUICKSTART.txt @@ -107,8 +107,9 @@ What the iPad says, and what to do: iPad", the PC is awake, and both are on the same Wi-Fi, not a guest network. If so, a firewall is probably blocking EternalMonitor: run EternalMonitor-Setup.exe again, or use a USB cable. -- "Your PC answered, but no picture arrived": follow any message in this - window, click Restart, then tap Try again on the iPad. +- "Your PC answered, but no picture arrived": choose Mirror my screen at + the top of the Stream page, then tap Try again on the iPad. If it + already mirrors, click Restart first. - "Lost the connection to your PC": wake the PC, check EternalMonitor is open, then tap Try again. - "That looks like the pairing code, not the PC's address": type the