MyKVM (2) 실제로 보이는 화면만 이어 붙이는 배치 계산
이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.
마우스를 화면 끝으로 밀면 옆 컴퓨터로 넘어가게 하려면, 먼저 “화면 끝 너머에 무엇이 있는지”를 알아야 한다. 내 책상은 이게 생각보다 복잡했다. 이번 글은 모니터 세 대의 상태를 어떻게 알아내고, 실제로 보이는 화면만 이어 붙여 하나의 배치로 만드는지다.
모니터마다 다르게 구는 상황
모니터는 세 대다. 맥북 화면, 맥과 윈도우에 동시에 꽂힌 LG 울트라와이드, 버튼으로 입력을 바꾸는 16MR. 윈도우를 쓸 때는 보통 LG와 16MR을 둘 다 윈도우 입력으로 바꾸고, 맥북 화면만 맥으로 남긴다. 그런데 두 모니터가 서로 다르게 군다.
- 16MR: 모니터 버튼으로 윈도우 입력으로 바꾸면 맥에서 사라진다. 맥이 “모니터가 빠졌다”고 알아챈다.
- LG: 맥 쪽(DP)과 윈도우 쪽(HDMI) 선이 둘 다 꽂혀 있어서, 화면에 윈도우가 떠 있어도 맥은 LG가 계속 연결돼 있다고 믿는다. 맥 커서가 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
}
넘어가는 경우는 두 가지다.
- LG처럼 맥이 있다고 믿는 화면은 macOS가 커서를 그 안까지 들여보낸다. 커서가 놓인 자리(
reported)가 윈도우 쪽 칸이면 넘어간다. - 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으로 모니터 변화 알기
CGDisplayRegisterReconfigurationCallback은 모니터가 연결되거나 빠지거나 배치가 바뀔 때 불릴 함수를 등록한다. 바뀌기 시작할 때 한 번(beginConfigurationFlag), 끝난 뒤 모니터마다 한 번씩 불려서, 시작 알림은 건너뛰고 끝난 알림에서만 목록을 다시 읽는다. 콜백이 C 함수라 이 객체를 직접 붙잡을 수 없어서, 주소를 넘겼다가Unmanaged로 다시 객체로 바꾼다.CGGetActiveDisplayList가 지금 쓰이는 모니터 번호 목록을 준다. 첫 인자가 받을 수 있는 최대 개수, 두 번째가 번호를 채울 배열, 세 번째가 실제 개수다.CGDisplayBounds가 그 모니터의 맥 전역 좌표 위치와 크기를 준다. 칸의frame이 이 값이다.- 모니터 번호는 다시 꽂으면 바뀔 수 있어서, 칸을 기억할 때는
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 디스플레이 배치를 그대로 따르기 때문이다.
