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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions mac/DisplayShare.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -178,6 +180,7 @@
36BF1183BD22B87CCC8CC97B /* ViewerClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewerClientTests.swift; sourceTree = "<group>"; };
3886DF372EA4C60956F2D2B4 /* FrameQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameQueueTests.swift; sourceTree = "<group>"; };
42B3E46961F40B0A4245C5BE /* Cadence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cadence.swift; sourceTree = "<group>"; };
47AD4A34075DD6D4CFFEAF61 /* DisplayPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayPlacement.swift; sourceTree = "<group>"; };
4CB2164BA0D66DD6A5C6FEC3 /* HelperProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperProtocol.swift; sourceTree = "<group>"; };
4F933202A0E898EE0C7DAB3B /* FrameQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameQueue.swift; sourceTree = "<group>"; };
4FD219E38307861BED3C1FA9 /* LineSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineSocket.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -240,6 +243,7 @@
DF94B2E6662744A3FC80AFC4 /* H264Dump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = H264Dump.swift; sourceTree = "<group>"; };
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 = "<group>"; };
E3F83080C1F57F9924D89B3C /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = "<group>"; };
E73BA65C147F0759185CF603 /* InputEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputEvents.swift; sourceTree = "<group>"; };
E7D93ED257B4998C87306BDF /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions mac/DisplayShare/DisplayShareApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
}
}

Expand Down
45 changes: 45 additions & 0 deletions mac/DisplayShareCore/DisplayPlacement.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
39 changes: 39 additions & 0 deletions mac/DisplayShareCore/DisplayShareController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -213,6 +214,7 @@ public final class DisplayShareController: ObservableObject {
}

public func stop() {
placement = nil
supervisor.stop()
pipeline.stop()
pipeline.stopServers()
Expand Down Expand Up @@ -241,13 +243,50 @@ 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))
state = .failed("\(error)")
}
}

/// 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.
///
Expand Down
64 changes: 64 additions & 0 deletions mac/DisplayShareCoreTests/DisplayPlacementTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading