From 4131e56cf73b9357a75a1c686be8c0dede1be5a1 Mon Sep 17 00:00:00 2001 From: nbkdoesntknowcoding Date: Wed, 26 Aug 2026 19:07:29 +0530 Subject: [PATCH] feat: say which side the second screen is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: the screen share connected and worked, and the cursor would not go to it. The obvious reading is that input forwarding is broken. It was not — the display was on the LEFT, and pushing right does nothing. macOS decides where a virtual display goes. The app asks for a size, never a position, and then said nothing at all about the result. So the one fact that determines which way you push was known to the app, visible in the window server, and absent from the interface. The popover now names the side, says to push that way, and offers the pane where the arrangement is changed — because telling somebody where a thing is without offering the way to move it is half an answer. Read back from NSScreen rather than assumed. The app does not choose the position, so the only honest source is where it actually ended up. `DisplayPlacement` is separate and pure so the decision is tested rather than eyeballed, including the one that would send the reader to exactly the wrong edge: Cocoa's y axis points up, so a greater y is physically higher. Where a display is offset on both axes, the dominant one wins, because that is the edge the cursor actually crosses. This is the same fault as the previous fix in different clothes. There, the receiver knew a session was refused and showed "Reconnecting…". Here, the sender knew where the display went and showed nothing. Each time the information exists and the interface declines to say it, and each time the user concludes the app is broken — reasonably, because from outside there is no difference. 209 Swift tests. Co-Authored-By: Claude Opus 5 --- README.md | 6 ++ mac/DisplayShare.xcodeproj/project.pbxproj | 12 +++- mac/DisplayShare/DisplayShareApp.swift | 19 ++++++ mac/DisplayShareCore/DisplayPlacement.swift | 45 +++++++++++++ .../DisplayShareController.swift | 39 +++++++++++ .../DisplayPlacementTests.swift | 64 +++++++++++++++++++ 6 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 mac/DisplayShareCore/DisplayPlacement.swift create mode 100644 mac/DisplayShareCoreTests/DisplayPlacementTests.swift diff --git a/README.md b/README.md index 4dc1274..75b2b21 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,12 @@ USEFUL TO KNOW: 2. Click **Start**. A second display appears immediately: open System Settings → Displays and it is there, arrangeable like any monitor. +> **macOS decides where to put it, and it usually lands on the left.** That is +> the edge your cursor has to cross to reach it — pushing right does nothing and +> looks exactly like input forwarding being broken. The popover names the side +> it went to; to move it, open System Settings → Displays and drag the second +> screen where you want it. + **On the laptop** 3. Launch the receiver. It finds the Mac over Bonjour, so there is no address to diff --git a/mac/DisplayShare.xcodeproj/project.pbxproj b/mac/DisplayShare.xcodeproj/project.pbxproj index d7471b9..c57ded2 100644 --- a/mac/DisplayShare.xcodeproj/project.pbxproj +++ b/mac/DisplayShare.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 191265899BC41B51512B4D4F /* FrameQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3886DF372EA4C60956F2D2B4 /* FrameQueueTests.swift */; }; 1A1CC8C401DE10F1567903EC /* ViewerClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BF1183BD22B87CCC8CC97B /* ViewerClientTests.swift */; }; 1BA013F9BF4776D89CB6B589 /* DSButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A25B2DC02E3ABDE8E68F4688 /* DSButton.swift */; }; + 1E3E9684AD1B78D269216A3C /* DisplayPlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AD4A34075DD6D4CFFEAF61 /* DisplayPlacement.swift */; }; 22224DD5384AEE68C9802010 /* InputInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FBE4E3EAB2C7F2A84C4B078 /* InputInjector.swift */; }; 22F6944D68034FD8606F7F07 /* AutoUpdaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3368C74E0990FC9733A9CE1 /* AutoUpdaterTests.swift */; }; 25F94343E08722F233CC3FDC /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BBFA61E802ABC429B866EB8 /* main.swift */; }; @@ -46,6 +47,7 @@ 67ACE413F33E8A4FD236FA66 /* vd_helper in Embed Dependencies */ = {isa = PBXBuildFile; fileRef = D64520181389425E0876F924 /* vd_helper */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 72AE37F09E9422CA303C32F8 /* AdaptiveBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A807FA4085741AD0E902444 /* AdaptiveBitrate.swift */; }; 737EDE391098F6FBA77243AE /* AnnexB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F208D6A8D52D56D3387A878 /* AnnexB.swift */; }; + 759D6F367E0B0DC776D1CE07 /* DisplayPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D46CD3D2A9001342BA7E6 /* DisplayPlacementTests.swift */; }; 7F45C79AE031ECE5BACBE5A3 /* UpdateCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2C6E5977F3295D00E6C349 /* UpdateCheckerTests.swift */; }; 800CF1428F4191B0863D5BBE /* CaptureSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE8F91DFAC29AB5561E417DF /* CaptureSession.swift */; }; 814EAACB1F9CE679613AF9E2 /* SendBackPressureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163614246029A1F2DD98831C /* SendBackPressureTests.swift */; }; @@ -178,6 +180,7 @@ 36BF1183BD22B87CCC8CC97B /* ViewerClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewerClientTests.swift; sourceTree = ""; }; 3886DF372EA4C60956F2D2B4 /* FrameQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameQueueTests.swift; sourceTree = ""; }; 42B3E46961F40B0A4245C5BE /* Cadence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cadence.swift; sourceTree = ""; }; + 47AD4A34075DD6D4CFFEAF61 /* DisplayPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayPlacement.swift; sourceTree = ""; }; 4CB2164BA0D66DD6A5C6FEC3 /* HelperProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperProtocol.swift; sourceTree = ""; }; 4F933202A0E898EE0C7DAB3B /* FrameQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameQueue.swift; sourceTree = ""; }; 4FD219E38307861BED3C1FA9 /* LineSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineSocket.swift; sourceTree = ""; }; @@ -240,6 +243,7 @@ DF94B2E6662744A3FC80AFC4 /* H264Dump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = H264Dump.swift; sourceTree = ""; }; E1D918B6A34E41BA47C3FD17 /* DisplayShareCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DisplayShareCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E1E0A1018FC2A639B0ED6B20 /* VideoToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VideoToolbox.framework; path = System/Library/Frameworks/VideoToolbox.framework; sourceTree = SDKROOT; }; + E20D46CD3D2A9001342BA7E6 /* DisplayPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayPlacementTests.swift; sourceTree = ""; }; E3F83080C1F57F9924D89B3C /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; E73BA65C147F0759185CF603 /* InputEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputEvents.swift; sourceTree = ""; }; E7D93ED257B4998C87306BDF /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; @@ -328,6 +332,7 @@ 3169965999D370874F1D93D5 /* CadenceTests.swift */, 7E2D2FF4EC6CFC2CE7BE0907 /* CaptureSessionTests.swift */, 6F579333895925E8ABEAD0DE /* CongestionSignalTests.swift */, + E20D46CD3D2A9001342BA7E6 /* DisplayPlacementTests.swift */, EFFE79E3FDC1E9A1E97805E5 /* DSButtonTests.swift */, 3886DF372EA4C60956F2D2B4 /* FrameQueueTests.swift */, 927652D6CA7E852FCCCEA3CD /* H264EncoderTests.swift */, @@ -459,6 +464,7 @@ 42B3E46961F40B0A4245C5BE /* Cadence.swift */, AE8F91DFAC29AB5561E417DF /* CaptureSession.swift */, 87809306C9A6439265F67D2A /* CongestionSignal.swift */, + 47AD4A34075DD6D4CFFEAF61 /* DisplayPlacement.swift */, 993F6B512F2560DD3EB1D7F6 /* DisplayShareController.swift */, 4F933202A0E898EE0C7DAB3B /* FrameQueue.swift */, 0DF270C9CEBD2FAC96C68F47 /* H264Encoder.swift */, @@ -660,6 +666,7 @@ 1BA013F9BF4776D89CB6B589 /* DSButton.swift in Sources */, 33E922A97A9BF88B4CAE79BC /* DSTextField.swift in Sources */, C20F9C39059D8DDC7B32D84C /* DesignTokens.swift in Sources */, + 1E3E9684AD1B78D269216A3C /* DisplayPlacement.swift in Sources */, A3AFDFBB04ADE15D67477635 /* DisplayShareController.swift in Sources */, EE75D37ED12C44BCA9D6E7BD /* FrameQueue.swift in Sources */, 5DC2EAF03A230AB3D347B690 /* H264Encoder.swift in Sources */, @@ -726,6 +733,7 @@ D74824A3B0782D62B839B6BD /* CaptureSessionTests.swift in Sources */, 8C6C6364F6BBF51894DC28D8 /* CongestionSignalTests.swift in Sources */, CCE4F9EEAF3B819228505867 /* DSButtonTests.swift in Sources */, + 759D6F367E0B0DC776D1CE07 /* DisplayPlacementTests.swift in Sources */, 191265899BC41B51512B4D4F /* FrameQueueTests.swift in Sources */, E9AE99F78920EA7CE20A4BF1 /* H264EncoderTests.swift in Sources */, 353310699F054EBAAE28F19F /* KeyMapTests.swift in Sources */, @@ -931,7 +939,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.15.0; + MARKETING_VERSION = 0.16.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; @@ -1047,7 +1055,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.15.0; + MARKETING_VERSION = 0.16.1; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; diff --git a/mac/DisplayShare/DisplayShareApp.swift b/mac/DisplayShare/DisplayShareApp.swift index fef7a03..b85aefa 100644 --- a/mac/DisplayShare/DisplayShareApp.swift +++ b/mac/DisplayShare/DisplayShareApp.swift @@ -611,6 +611,25 @@ private struct ControlPanel: View { .font(.caption2) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + + // Where macOS put it. The app chooses a size, not a position, and + // never used to say where the display landed — so the cursor did + // not cross the edge people pushed against, and the obvious + // conclusion was that input forwarding had failed. It had not. + if let placement = controller.placement { + Divider().overlay(DSColor.border) + VStack(alignment: .leading, spacing: DSSpacing.s1) { + Text(placement.describedForUser) + .font(.system(size: DSFont.f2, weight: .medium)) + Text("Push your cursor that way to reach it.") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + DSButton("Rearrange in Display Settings…", variant: .ghost) { + controller.openDisplaySettings() + } + } + } } } diff --git a/mac/DisplayShareCore/DisplayPlacement.swift b/mac/DisplayShareCore/DisplayPlacement.swift new file mode 100644 index 0000000..7b90aa1 --- /dev/null +++ b/mac/DisplayShareCore/DisplayPlacement.swift @@ -0,0 +1,45 @@ +import CoreGraphics +import Foundation + +/// Where macOS put the second screen, so the app can say so. +/// +/// A virtual display is created without a position and macOS places it — in +/// practice to the left of the main screen. Nothing tells the user that, so the +/// cursor does not cross the edge they push against, and the reasonable +/// conclusion is that input forwarding is broken. It is not: the screen is on +/// the other side. +/// +/// This is the same fault this project keeps shipping in different clothes. The +/// app knows a specific fact, shows a generic one, and the user is left to infer +/// the difference from behaviour. The fix is never a better guess downstream; it +/// is saying the thing that is already known. +public enum DisplayPlacement: String, Sendable, Equatable, CaseIterable { + case left, right, above, below + + /// How it reads in the interface. Deliberately about the direction to push, + /// because that is the action the reader is trying to take. + public var describedForUser: String { + switch self { + case .left: return "Your second screen is to the left" + case .right: return "Your second screen is to the right" + case .above: return "Your second screen is above this one" + case .below: return "Your second screen is below this one" + } + } + + /// Where macOS placed `secondary` relative to `main`. + /// + /// Whichever axis is more separated wins. A display can be offset on both — + /// slightly above and far to the left is normally described as "to the + /// left", because that is the edge the cursor actually crosses. + public static func of(_ secondary: CGRect, relativeTo main: CGRect) -> DisplayPlacement { + let dx = secondary.midX - main.midX + let dy = secondary.midY - main.midY + + if abs(dx) >= abs(dy) { + return dx < 0 ? .left : .right + } + // Cocoa's y axis points up, so a greater midY is physically higher. + return dy > 0 ? .above : .below + } +} diff --git a/mac/DisplayShareCore/DisplayShareController.swift b/mac/DisplayShareCore/DisplayShareController.swift index 3cfcd43..f5fe5d0 100644 --- a/mac/DisplayShareCore/DisplayShareController.swift +++ b/mac/DisplayShareCore/DisplayShareController.swift @@ -180,6 +180,7 @@ public final class DisplayShareController: ObservableObject { supervisor.start() supervisor.noteSessionStarted() streamURL = "http://\(Self.primaryIPv4Address() ?? "localhost"):\(port)" + refreshPlacement(displayID: displayID) state = .active(displayID: displayID) } catch let error as CaptureSession.CaptureError { FileHandle.standardError.write(Data("[DisplayShare] capture failed: \(error)\n".utf8)) @@ -213,6 +214,7 @@ public final class DisplayShareController: ObservableObject { } public func stop() { + placement = nil supervisor.stop() pipeline.stop() pipeline.stopServers() @@ -241,6 +243,7 @@ public final class DisplayShareController: ObservableObject { } // Geometry changed, so the coordinate mapping must follow it. injector.setDisplay(displayID) + refreshPlacement(displayID: displayID) state = .active(displayID: displayID) } catch { FileHandle.standardError.write(Data("[DisplayShare] reconfigure failed: \(error)\n".utf8)) @@ -248,6 +251,42 @@ public final class DisplayShareController: ObservableObject { } } + /// Which side of the main screen macOS put the second display on. + /// + /// `nil` until there is one. Published because the answer decides which way + /// the user pushes their cursor, and getting it wrong looks exactly like + /// input forwarding being broken. + @Published public private(set) var placement: DisplayPlacement? + + /// Reads the placement back from the window server rather than assuming it. + /// + /// macOS positions a virtual display itself — the app asks for a size, not + /// a location — so the only honest source is where it actually ended up. + private func refreshPlacement(displayID: UInt32) { + let screens = NSScreen.screens + let key = NSDeviceDescriptionKey("NSScreenNumber") + let secondary = screens.first { + ($0.deviceDescription[key] as? NSNumber)?.uint32Value == displayID + } + guard let secondary, + let main = screens.first(where: { + ($0.deviceDescription[key] as? NSNumber)?.uint32Value != displayID + }) + else { + placement = nil + return + } + placement = DisplayPlacement.of(secondary.frame, relativeTo: main.frame) + } + + /// Opens the pane where the arrangement is changed, since telling someone + /// where a thing is without offering the way to move it is half an answer. + public func openDisplaySettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.displays") + else { return } + NSWorkspace.shared.open(url) + } + /// The user asked for the virtual display to go away so protected video /// would play, and has not asked for it back. /// diff --git a/mac/DisplayShareCoreTests/DisplayPlacementTests.swift b/mac/DisplayShareCoreTests/DisplayPlacementTests.swift new file mode 100644 index 0000000..3d2cd3c --- /dev/null +++ b/mac/DisplayShareCoreTests/DisplayPlacementTests.swift @@ -0,0 +1,64 @@ +import CoreGraphics +import XCTest + +@testable import DisplayShareCore + +/// Saying where the second screen went. +/// +/// The bug this closes is not a crash or a wrong pixel: the app placed a +/// display, knew exactly where, and left the user pushing their cursor at the +/// wrong edge and concluding that input forwarding was broken. +final class DisplayPlacementTests: XCTestCase { + + private let main = CGRect(x: 0, y: 0, width: 2560, height: 1080) + + /// What macOS actually does with a virtual display, and the case that was + /// reported: it lands on the left, and pushing right does nothing. + func testTheDefaultPlacementIsRecognisedAsLeft() { + let virtual = CGRect(x: -1920, y: 0, width: 1920, height: 1080) + XCTAssertEqual(DisplayPlacement.of(virtual, relativeTo: main), .left) + } + + func testAScreenPlacedToTheRight() { + XCTAssertEqual( + DisplayPlacement.of( + CGRect(x: 2560, y: 0, width: 1920, height: 1080), relativeTo: main), + .right) + } + + /// Cocoa's y axis points up, so a greater y is physically higher. Getting + /// this backwards would send the reader to precisely the wrong edge. + func testVerticalPlacementFollowsCocoaCoordinates() { + XCTAssertEqual( + DisplayPlacement.of( + CGRect(x: 0, y: 1080, width: 2560, height: 1080), relativeTo: main), + .above) + XCTAssertEqual( + DisplayPlacement.of( + CGRect(x: 0, y: -1080, width: 2560, height: 1080), relativeTo: main), + .below) + } + + /// Offset on both axes is normal. The answer must be the edge the cursor + /// actually crosses, which is the one it is further along. + func testTheDominantAxisDecides() { + // Far left, slightly high. + let mostlyLeft = CGRect(x: -1920, y: 200, width: 1920, height: 1080) + XCTAssertEqual(DisplayPlacement.of(mostlyLeft, relativeTo: main), .left) + + // Slightly left, far above. + let mostlyAbove = CGRect(x: -200, y: 1400, width: 1920, height: 1080) + XCTAssertEqual(DisplayPlacement.of(mostlyAbove, relativeTo: main), .above) + } + + /// Every case must produce copy that names a direction, or the sentence + /// tells the reader nothing they can act on. + func testEveryPlacementNamesADirection() { + for placement in DisplayPlacement.allCases { + let text = placement.describedForUser.lowercased() + XCTAssertTrue( + ["left", "right", "above", "below"].contains(where: text.contains), + "\(placement) reads as \"\(text)\", which names no direction") + } + } +}