포스트

MyKVM (2) 실제로 보이는 화면만 이어 붙이는 배치 계산

이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.

마우스를 화면 끝으로 밀면 옆 컴퓨터로 넘어가게 하려면, 먼저 “화면 끝 너머에 무엇이 있는지”를 알아야 한다. 내 책상은 이게 생각보다 복잡했다. 이번 글은 모니터 세 대의 상태를 어떻게 알아내고, 실제로 보이는 화면만 이어 붙여 하나의 배치로 만드는지다.


모니터마다 다르게 구는 상황

모니터는 세 대다. 맥북 화면, 맥과 윈도우에 동시에 꽂힌 LG 울트라와이드, 버튼으로 입력을 바꾸는 16MR. 윈도우를 쓸 때는 보통 LG와 16MR을 둘 다 윈도우 입력으로 바꾸고, 맥북 화면만 맥으로 남긴다. 그런데 두 모니터가 서로 다르게 군다.

  • 16MR: 모니터 버튼으로 윈도우 입력으로 바꾸면 맥에서 사라진다. 맥이 “모니터가 빠졌다”고 알아챈다.
  • LG: 맥 쪽(DP)과 윈도우 쪽(HDMI) 선이 둘 다 꽂혀 있어서, 화면에 윈도우가 떠 있어도 맥은 LG가 계속 연결돼 있다고 믿는다. 맥 커서가 LG 쪽으로 가면 보이지 않는 화면 속으로 사라진다.

윈도우 모드에서는 맥북만 맥 화면이고 LG와 16MR은 윈도우 화면이다. LG는 맥이 여전히 연결됐다고 믿어서 스왑 단축키로 알려 주고, 16MR은 맥에서 사라져서 자동으로 안다. 맥북 오른쪽 끝으로 밀면 윈도우의 LG로 넘어간다

그래서 모니터마다 “지금 맥 화면인지 윈도우 화면인지”를 따로 기억하고, 실제로 보이는 화면만 이어 붙여 하나의 배치로 본다. 16MR처럼 사라지는 모니터는 자동으로 정하고, LG처럼 알 방법이 없는 모니터는 스왑 단축키(⌥⌘X)로 사람이 알려 준다. 모니터 버튼을 누를 때 단축키도 같이 누르는 식이다.


맥 전역 좌표란?

맥은 연결된 모든 화면을 하나의 큰 좌표판 위에 놓는다. 주 화면(내 경우 LG)의 왼쪽 위가 (0, 0)이고, 오른쪽으로 갈수록 x가, 아래로 갈수록 y가 커진다. 시스템 설정의 디스플레이 배치에서 모니터 네모를 끌어 놓는 그 배치가 이 좌표판이다. 내 맥에서는 LG가 (0, 0)부터 3440 x 1440, 맥북이 x -1512부터, 16MR이 LG 바로 아래 (913, 1440)부터 놓여 있다.

MyKVM은 커서가 윈도우 화면에 가 있을 때도 위치를 이 맥 좌표로 계속 들고 있는다. 그래야 “왼쪽으로 밀어서 맥북 화면에 닿았다”를 맥이 혼자 계산할 수 있다. 윈도우로 보낼 때만 윈도우 좌표로 바꾼다.


칸과 주인으로 나타낸 배치

배치는 “칸”의 목록이다. 칸 하나가 책상 위 실제 모니터 한 자리다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// ScreenLayout.swift (KVMCore)
public struct Slot: Codable, Equatable, Sendable {
    /// 맥 디스플레이 UUID
    public var id: String
    public var name: String
    public var frame: Rect
    public var mode: SlotMode
    public var owner: Owner
    public var presentOnMac: Bool
    /// 이 자리에 해당하는 윈도우 디스플레이 (배치 창에서 짝지음)
    public var windowsDisplayID: String?
    // 생략
}
속성뜻
frame맥 전역 좌표에서의 위치와 크기. 맥이 못 보는 동안은 마지막으로 본 값을 기억한다
mode주인을 정하는 방식. 맥 전용(맥북), 자동(16MR), 스왑(LG)
owner지금 이 자리에 보이는 화면이 맥인지 윈도우인지
presentOnMac맥이 지금 이 모니터를 보고 있는지
windowsDisplayID이 자리가 윈도우의 어느 화면인지

Codable은 이 구조체를 JSON으로 저장하고 다시 읽을 수 있게 해 주는 Swift 기본 약속이다. 배치는 ~/Library/Application Support/MyKVM/config.json에 저장된다.

마우스가 움직일 때마다 “이 움직임으로 윈도우에 넘어가야 하나”를 판단하는 부분이 아래 코드다.

1
2
3
4
5
6
7
8
9
10
11
12
// ScreenLayout.swift (KVMCore)
public func resolveMacMove(previous: Point, reported: Point, delta: Vector) -> MacMoveDecision {
    // LG처럼 맥이 아직 있다고 믿는 모니터가 윈도우 쪽이면, macOS는 커서를 그 안으로 들여보낸다
    if let slot = slot(at: reported), slot.owner == .windows {
        return isUsable(slot) ? .crossToWindows(entry: reported) : .blockAndWarp(to: previous)
    }
    let candidate = previous + delta
    if let slot = usableSlot(at: candidate), slot.owner == .windows {
        return .crossToWindows(entry: candidate)
    }
    return .stay
}

넘어가는 경우는 두 가지다.

  1. LG처럼 맥이 있다고 믿는 화면은 macOS가 커서를 그 안까지 들여보낸다. 커서가 놓인 자리(reported)가 윈도우 쪽 칸이면 넘어간다.
  2. 16MR처럼 맥이 모르는 화면은 macOS가 LG 아래 끝에서 커서를 막아 버린다. 그래서 막히기 전 위치에 움직인 양을 더한 자리(previous + delta)가 윈도우 쪽 칸이면 넘어간다.

윈도우가 아직 연결되지 않았는데 커서가 보이지 않는 LG로 들어가면, 직전 자리로 되돌린다(blockAndWarp). 이 계산은 화면이나 OS를 모르는 순수한 Swift 코드라서 내 맥의 실제 좌표를 넣은 테스트로 확인했다.


비율로 윈도우 좌표 옮기기

같은 LG라도 맥과 윈도우의 해상도나 배율이 다를 수 있다. 16MR은 실제로 맥에서 1600 x 1000, 윈도우에서 1680 x 1050으로 보인다. 그래서 칸 안의 위치를 비율로 바꾼다. 칸 왼쪽에서 30% 떨어진 점이면 윈도우 화면에서도 왼쪽에서 30% 떨어진 점으로 보낸다.

1
2
3
4
5
6
7
// ScreenLayout.swift (KVMCore)
public func toWindows(_ p: Point) -> (x: Int, y: Int)? {
    guard let slot = usableSlot(at: p), slot.owner == .windows, let d = windowsDisplay(for: slot) else { return nil }
    let x = Int(((p.x - slot.frame.x) * Double(d.w) / slot.frame.w).rounded(.down))
    let y = Int(((p.y - slot.frame.y) * Double(d.h) / slot.frame.h).rounded(.down))
    return (d.x + min(max(x, 0), d.w - 1), d.y + min(max(y, 0), d.h - 1))
}

마지막 줄의 min, max는 계산 결과가 화면 밖(오른쪽 끝 바로 다음 픽셀 등)으로 나가지 않게 잘라 주는 부분이다.


모니터가 빠지고 들어오는 것 알아채기

16MR을 버튼으로 윈도우에 넘기면 맥에서 모니터 하나가 빠진다. 이걸 알아채서 16MR 칸의 주인을 윈도우로 바꿔야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// DisplayWatcher.swift (맥 앱)
CGDisplayRegisterReconfigurationCallback({ _, flags, context in
    // 바뀌기 시작할 때 한 번, 끝난 뒤 디스플레이마다 한 번씩 온다. 끝난 쪽만 본다.
    guard !flags.contains(.beginConfigurationFlag), let context else { return }
    let watcher = Unmanaged<DisplayWatcher>.fromOpaque(context).takeUnretainedValue()
    DispatchQueue.main.async { MainActor.assumeIsolated { watcher.onChange(watcher.current()) } }
}, context)

func current() -> [MacDisplayInfo] {
    var ids = [CGDirectDisplayID](repeating: 0, count: 16)
    var count: UInt32 = 0
    guard CGGetActiveDisplayList(16, &ids, &count) == .success else { return [] }
    return ids.prefix(Int(count)).compactMap { id in
        // 생략: UUID, 이름 구하기
        let bounds = CGDisplayBounds(id)
        // 생략
    }
}

CGDisplayRegisterReconfigurationCallback으로 모니터 변화 알기

  1. CGDisplayRegisterReconfigurationCallback은 모니터가 연결되거나 빠지거나 배치가 바뀔 때 불릴 함수를 등록한다. 바뀌기 시작할 때 한 번(beginConfigurationFlag), 끝난 뒤 모니터마다 한 번씩 불려서, 시작 알림은 건너뛰고 끝난 알림에서만 목록을 다시 읽는다. 콜백이 C 함수라 이 객체를 직접 붙잡을 수 없어서, 주소를 넘겼다가 Unmanaged로 다시 객체로 바꾼다.
  2. CGGetActiveDisplayList가 지금 쓰이는 모니터 번호 목록을 준다. 첫 인자가 받을 수 있는 최대 개수, 두 번째가 번호를 채울 배열, 세 번째가 실제 개수다.
  3. CGDisplayBounds가 그 모니터의 맥 전역 좌표 위치와 크기를 준다. 칸의 frame이 이 값이다.
  4. 모니터 번호는 다시 꽂으면 바뀔 수 있어서, 칸을 기억할 때는 CGDisplayCreateUUIDFromDisplayID로 얻은 UUID(모니터마다 고정된 이름표)를 쓴다. 화면에 보일 이름은 NSScreen.localizedName에서 가져온다.

목록에서 빠진 칸은 presentOnMac을 끄고, 자동 칸이면 주인을 윈도우로 바꾼다. 위치는 마지막 값을 그대로 둬서, 맥이 16MR을 못 보는 동안에도 그 자리가 어디인지 안다. 그래서 MyKVM을 처음 켤 때 16MR이 윈도우 쪽이면, 한 번 맥으로 돌려 줘야 그 자리가 생긴다.


윈도우 화면과 짝 맞추기

비율로 옮기려면 맥의 LG 칸이 윈도우의 어느 화면인지 알아야 한다. 처음에는 배치 창에서 사람이 골라 주게 했는데, 안 고르면 스왑해도 넘어갈 곳이 없어 아무 일도 안 일어났다. 실제로 그걸 빠뜨려서 “스왑이 안 된다”는 일이 생겼다. 그래서 짝을 스스로 맞추게 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ScreenLayout.swift (KVMCore)
for i in needsMapping {
    let key = normalized(slots[i].name)
    guard !key.isEmpty, let match = windowsDisplays.first(where: { !used.contains($0.id) && normalized($0.name).contains(key) })
    else { continue }
    slots[i].windowsDisplayID = match.id
    used.insert(match.id)
}
for i in needsMapping where !(slots[i].windowsDisplayID.map(validIDs.contains) ?? false) {
    let ratio = slots[i].frame.w / slots[i].frame.h
    let sameShape = windowsDisplays.filter { !used.contains($0.id) && abs(Double($0.w) / Double($0.h) - ratio) / ratio < 0.02 }
    guard sameShape.count == 1 else { continue }
    slots[i].windowsDisplayID = sameShape[0].id
    used.insert(sameShape[0].id)
}

먼저 이름으로 찾는다. LG는 윈도우에서도 “LG ULTRAWIDE(HDMI)”라서 이름으로 찾아진다. 16MR은 윈도우에서 “Generic PnP Monitor”라는 이름 없는 모니터로 보여서, 화면 비율로 찾는다. 맥에서 1600 x 1000, 윈도우에서 1680 x 1050으로 해상도는 달라도 둘 다 16:10이다. 비율이 맞는 화면이 하나뿐일 때만 짝을 맞추고, 배치 창에서 직접 고른 짝은 건드리지 않는다.

그래도 짝이 없으면 메뉴 막대에 “배치 창에서 골라 주세요”를 띄운다. 처음에는 이 안내가 잘못 떴다. 16MR을 맥으로 돌리면 윈도우는 16MR을 더 이상 못 보니 화면 목록에 LG만 보내는데, 맥이 “16MR의 윈도우 화면이 없다”고 본 것이다. 16MR은 지금 맥 쪽이라 윈도우 화면이 필요 없다. 그래서 지금 윈도우 쪽인 칸과, 스왑으로 언제든 윈도우가 될 수 있는 칸만 묻게 고쳤다.


배치 창

메뉴 막대의 “배치…”를 누르면 칸들이 실제 비율대로 그려진 창이 뜬다. 맥 쪽 칸은 회색, 윈도우 쪽 칸은 청록이고, 칸을 누르면 아래에서 방식과 윈도우 화면을 고를 수 있다.

1
2
3
4
5
6
7
8
9
10
// LayoutWindow.swift (맥 앱)
ForEach(visibleSlots, id: \.id) { slot in
    Button { selectedID = slot.id } label: {
        SlotTile(slot: slot, usable: layout.isUsable(slot), selected: slot.id == selectedID)
    }
    .buttonStyle(.plain)
    .frame(width: slot.frame.w * scale - 4, height: slot.frame.h * scale - 4)
    .offset(x: (slot.frame.x - minX) * scale + 2, y: (slot.frame.y - minY) * scale + 2)
    .accessibilityLabel("\(slot.name), \(slot.owner == .mac ? "맥" : "윈도우")")
}

Button과 accessibilityLabel로 누를 수 있는 칸 만들기

칸은 Button으로 만들고 .buttonStyle(.plain)으로 버튼 모양을 없앴다. 처음에는 그림에 onTapGesture를 붙였는데, 그건 마우스 클릭에만 반응하고 손쉬운 사용(VoiceOver, 키보드)의 “누르기”에는 반응하지 않았다. 버튼으로 바꾸니 둘 다 된다. accessibilityLabel(_:)은 VoiceOver가 읽어 줄 이름이다. 칸의 위치는 맥 배치를 축소한 값을 offset(x:y:)로 준다. 끌어서 옮기는 기능은 넣지 않았다. 위치는 macOS 디스플레이 배치를 그대로 따르기 때문이다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.