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") + } + } +}