Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
82 changes: 76 additions & 6 deletions ios/EternalMonitor/App/ConnectionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID?>(initialState: nil)
private var frameAssembler: FrameAssembler?
private var controlChannel: ControlChannel?
Expand All @@ -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
Expand Down Expand Up @@ -232,6 +243,8 @@ final class ConnectionManager: ObservableObject {
}

func resumeUSBConnections() {
noPictureStreak.restartCount()
usbStoppedForNoPicture = false
usbPaused = false
refreshUSBAvailability()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -1028,7 +1098,7 @@ final class ConnectionManager: ObservableObject {
func handleAppForegrounded() {
endBackgroundGoodbye()
isForeground = true
usbPaused = false
usbPaused = usbStoppedForNoPicture
refreshUSBAvailability()
guard resumeOnForeground else { return }
resumeOnForeground = false
Expand Down
76 changes: 74 additions & 2 deletions ios/EternalMonitor/App/ConnectionProblem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -246,6 +246,78 @@ enum ConnectionProblem: Equatable {
private static let offline: Set<POSIXErrorCode> = [.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 {
Expand Down
21 changes: 21 additions & 0 deletions ios/EternalMonitor/App/DiagnosticsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion ios/EternalMonitor/Core/DiagnosticsUpload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 7 additions & 1 deletion ios/EternalMonitor/Core/Feedback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,7 +43,8 @@ enum Feedback {
systemVersion: UIDevice.current.systemVersion,
model: modelIdentifier(),
pcVersion: pcVersion,
connection: connection
connection: connection,
supportID: DiagnosticsUpload.shared.installationID
)
}

Expand Down Expand Up @@ -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)")
}
Expand Down
Loading
Loading