포스트

MyKVM (13) 맥과 윈도우가 복사한 텍스트를 나눠 쓰는 클립보드 공유

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

키보드와 마우스는 이제 맥과 윈도우를 자유롭게 오간다. 그런데 윈도우에서 로그를 복사해 맥에 붙이려면, 여전히 로그를 파일로 NAS에 저장하고 맥에서 그 파일을 다시 열어야 했다. 그래서 부가 기능으로 클립보드 공유를 넣었다. 맥에서 ⌘C, 윈도우에서 Ctrl+V. 윈도우에서 Ctrl+C, 맥에서 ⌘V. 평소 복사, 붙여넣기 그대로이고 따로 누를 버튼은 없다. 이번에는 텍스트만 옮긴다.


복사한 텍스트를 반대편 클립보드로

한쪽에서 복사하면 그 텍스트를 지금 연결(7편의 암호화 봉투)로 보내고, 반대편은 받은 텍스트를 자기 클립보드에 넣는다. 키보드가 어느 쪽에 꽂혀 있든, 커서가 어느 화면에 있든 상관없이 마지막에 복사한 것이 양쪽에 있다.


클립보드란?

OS가 “방금 복사한 것”을 잠깐 담아 두는 칸이다. 앱이 복사하면 이 칸에 넣고, 붙여넣으면 이 칸에서 꺼낸다. 칸은 하나라 새로 복사하면 앞의 것은 지워진다. 같은 내용을 여러 모양(일반 텍스트, 서식 있는 텍스트, 그림)으로 동시에 담을 수 있는데, MyKVM은 그중 일반 텍스트만 본다.

상황하는 일
연결된 상태에서 복사반대편 클립보드에 넣는다
연결이 끊긴 동안 복사보내지 않는다. 다시 붙은 뒤에 새로 복사한 것부터 넘어간다
사진만 복사보내지 않는다. 반대편은 그대로
8MB가 넘는 텍스트보내지 않는다
맥 메뉴의 “클립보드 공유” 끔맥이 보내지도 받지도 않는다

맥에서 복사 알아채기

macOS는 “클립보드가 바뀌었다”는 알림을 주지 않는다. 대신 클립보드가 바뀔 때마다 1씩 올라가는 번호가 있어서, 그 번호를 0.5초마다 본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ClipboardSync.swift (MyKVMMac)
private func check() {
    let changeCount = pasteboard.changeCount
    guard changeCount != lastChangeCount else { return }
    lastChangeCount = changeCount
    // 끊겨 있던 동안 복사한 것은 보내지 않는다. 다시 붙은 뒤 새로 복사한 것부터.
    guard enabled, connected else { return }
    guard let raw = pasteboard.string(forType: .string) else {
        echo.localChangedWithoutText()
        return
    }
    let text = Clip.toWire(raw)
    guard echo.shouldSend(text, token: changeCount, now: ProcessInfo.processInfo.systemUptime) else { return }
    guard let pieces = Clip.split(text, id: UUID().uuidString) else { return }
    send(pieces)
}

NSPasteboard의 changeCount로 바뀐 것 알아채기

  1. NSPasteboard.general은 복사, 붙여넣기에 쓰는 맥의 기본 클립보드다.
  2. changeCount는 클립보드 내용이 바뀔 때마다 올라가는 숫자다. 지난번에 본 값과 다르면 누가 새로 복사한 것이다. 내용을 매번 읽어 비교하는 것보다 훨씬 가볍다.
  3. string(forType: .string)은 클립보드에서 일반 텍스트를 꺼낸다. 텍스트가 없으면(사진만 복사 등) nil이라 보내지 않는다.
  4. 받은 텍스트를 넣을 때는 clearContents()로 비우고 setString(_:forType:)으로 넣는다. 비우지 않으면 넣을 수 없다.

systemUptime은 맥을 켠 뒤 흐른 초다. 시계를 바꿔도 거꾸로 가지 않아서 “1초 안에” 같은 비교에 쓴다(아래 되돌아오기 막기).


윈도우에서 복사 알아채기

윈도우는 반대로 알림을 준다. 10편의 잠김 알림처럼 숨은 창을 하나 만들어 등록해 두면, 클립보드가 바뀔 때마다 그 창에 WM_CLIPBOARDUPDATE 메시지가 온다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// ClipboardWatcher.cs (MyKVM.Win)
public ClipboardWatcher()
{
    CreateHandle(new CreateParams());
    AddClipboardFormatListener(Handle);
    _readRetry.Tick += (_, _) => TryRead();
    _writeRetry.Tick += (_, _) => TryWrite();
}

void TryRead()
{
    _readRetry.Stop();
    var token = GetClipboardSequenceNumber();
    if (!IsClipboardFormatAvailable(CF_UNICODETEXT))
    {
        Copied?.Invoke(null, token);
        return;
    }
    if (!OpenClipboard(Handle))
    {
        if (++_readTries < RetryLimit) _readRetry.Start();
        return;
    }
    string? text = null;
    try
    {
        var handle = GetClipboardData(CF_UNICODETEXT);
        var pointer = handle == IntPtr.Zero ? IntPtr.Zero : GlobalLock(handle);
        if (pointer != IntPtr.Zero)
        {
            text = Marshal.PtrToStringUni(pointer);
            GlobalUnlock(handle);
        }
    }
    finally { CloseClipboard(); }
    if (text is { Length: > 0 }) Copied?.Invoke(text, token);
}

AddClipboardFormatListener와 OpenClipboard로 텍스트 읽기

  1. AddClipboardFormatListener에 숨은 창을 등록하면, 클립보드가 바뀔 때마다 그 창에 알림이 온다.
  2. GetClipboardSequenceNumber는 맥의 changeCount와 같은 번호다. 클립보드가 바뀔 때마다 올라간다.
  3. IsClipboardFormatAvailable(CF_UNICODETEXT)로 유니코드 텍스트가 들어 있는지 먼저 본다. CF_UNICODETEXT는 윈도우가 정해 둔 표준 클립보드 형식 중 하나다.
  4. OpenClipboard로 클립보드를 연다. 한 번에 한 앱만 열 수 있어서, 다른 앱이 열고 있으면 실패한다.
  5. GetClipboardData가 텍스트가 든 메모리 덩어리의 손잡이를 준다. GlobalLock으로 그 덩어리의 주소를 얻고, Marshal.PtrToStringUni로 C# 글자로 읽은 뒤 GlobalUnlock으로 놓는다.
  6. 끝나면 반드시 CloseClipboard로 닫는다. 안 닫으면 다른 앱이 클립보드를 못 쓴다.

넣을 때는 반대 순서다. 클립보드를 열고 EmptyClipboard로 비운 뒤, GlobalAlloc으로 메모리를 잡아 텍스트를 쓰고 SetClipboardData에 넘긴다. 넘긴 메모리는 그때부터 윈도우가 관리한다.


기다리지 않는 UI 스레드

처음에는 WinForms의 Clipboard.GetText를 썼고, 다른 앱이 클립보드를 잡고 있으면 0.05초씩 쉬며 다시 시도했다. 그런데 이 방식은 위험했다. Clipboard.GetText는 안에서 이미 0.1초씩 열 번까지 다시 시도한다. 그 위에 MyKVM이 또 쉬면 최악에는 몇 초 동안 UI 스레드가 멈춘다. 그런데 이 스레드는 11편의 저수준 훅도 돌린다. 훅이 늦으면 윈도우 전체의 마우스와 키보드가 끊기고, 계속 늦으면 윈도우가 훅을 말없이 떼어 버린다.

그래서 윈도우 함수를 직접 불러, 클립보드가 잡혀 있으면 그 자리에서 바로 포기하게 했다. 다시 시도는 기다리지 않고 WinForms 타이머로 0.05초 뒤에 다시 부르는 방식으로, 다섯 번까지 한다. 그 사이 UI 스레드는 계속 다른 일을 한다.


큰 로그를 조각으로 나눠 보내기

로그는 몇 MB가 되기도 한다. 그런데 7편의 프레임 하나는 1MB까지라 한 메시지에 담을 수 없다. 그래서 텍스트를 조각으로 나눠 clip이라는 새 메시지로 보낸다.

1
{ "t": "clip", "id": "8F2C…", "index": 0, "count": 23, "text": "조각 내용" }

같은 id의 조각 count개를 index 순서대로 모으면 원래 텍스트가 된다. 받는 쪽은 다 모였을 때만 클립보드에 넣는다. 다른 id의 조각이 오면 모으던 것은 버린다. 그 사이 새로 복사했다는 뜻이라 최신 것이 이긴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Clip.swift (KVMCore)
public static func split(_ text: String, id: String, pieceBytes: Int = Clip.pieceBytes, maxBytes: Int = Clip.maxBytes) -> [Message]? {
    let total = text.utf8.count
    guard total > 0, total <= maxBytes else { return nil }
    var pieces: [String] = []
    var current = "", currentBytes = 0
    for character in text {
        let bytes = character.utf8.count
        if currentBytes > 0, currentBytes + bytes > pieceBytes {
            pieces.append(current)
            current = ""
            currentBytes = 0
        }
        current.append(character)
        currentBytes += bytes
    }
    pieces.append(current)
    return pieces.enumerated().map { .clip(id: id, index: $0.offset, count: pieces.count, text: $0.element) }
}

조각 크기는 글자 수가 아니라 UTF-8 바이트로 잰다. 영어는 한 글자가 1바이트지만 한글은 3바이트, 이모지는 4바이트다. 글자를 하나씩 더하다가 다음 글자를 넣으면 한도를 넘을 때 새 조각을 시작하니, 한 글자가 두 조각에 걸쳐 잘리는 일이 없다. 윈도우 쪽은 C#의 Rune(유니코드 글자 하나)으로 같은 일을 한다.


JSON 이스케이프란?

JSON 글자 안에 그대로 쓸 수 없는 글자를 다른 모양으로 바꿔 쓰는 것이다. 따옴표(")는 글자의 끝으로 오해받으니 \"로 쓰고, 눈에 안 보이는 제어 문자는 \u001b처럼 번호로 쓴다. C#의 기본 JSON 라이브러리는 안전을 위해 <, >, +, ', 한글까지 번호 모양으로 바꾼다.

처음에는 조각을 256KB로 잡았는데, 이게 문제였다. 윈도우 쪽에서 조각 하나가 JSON이 되면서 얼마나 커지는지 재어 보면 이렇다.

조각(256KB)의 글자JSON 크기
영어약 262KB
한글약 524KB
이모지약 786KB
<, +, 제어 문자약 1.57MB

\u001b 한 덩어리가 6바이트라 원래보다 6배까지 커진다. 색 코드(제어 문자 ESC)가 섞인 로그를 복사하면 조각 하나가 1MB를 넘고, 받는 쪽은 7편의 길이 검사에서 연결이 깨진 것으로 보고 끊어 버린다. 그래서 조각을 128KB로 줄였다. 6배가 되어도 768KB라 봉투를 더해도 1MB 안에 들어간다. 제어 문자, <, +, 한글로 100만 글자를 만들어 모든 조각이 한 프레임에 들어가는지 보는 테스트를 양쪽에 넣었다.


한 조각씩 보내는 이유

3MB 로그는 128KB 조각 23개가 된다. 23조각을 한꺼번에 연결에 밀어 넣으면, 그 뒤에 보낸 키와 마우스 메시지는 23조각이 다 나갈 때까지 줄을 선다. 그동안 커서가 멈춘다. 그래서 앞 조각이 실제로 나간 뒤에 다음 조각을 보낸다.

위 줄은 23조각을 한 번에 넣어서 키가 23조각 뒤에서 출발하고, 아래 줄은 한 조각씩 보내서 키가 앞 조각 하나만 기다리고 조각 1과 조각 2 사이에 끼어든다

1
2
3
4
5
6
7
8
9
// KVMServer.swift (KVMCore)
private func sendNext(_ rest: ArraySlice<Message>, on connection: Connection, generation: Int) {
    guard generation == sequenceGeneration, connection === active, let message = rest.first,
          case let .send(frame)? = connection.session.send(message) else { return }
    connection.nw.send(content: frame, completion: .contentProcessed { [weak self, weak connection] error in
        guard error == nil, let self, let connection else { return }
        self.queue.async { self.sendNext(rest.dropFirst(), on: connection, generation: generation) }
    })
}

NWConnection.send의 contentProcessed로 다음 조각 보내기

send(content:contentContext:isComplete:completion:)는 데이터를 연결에 넣는다. 마지막 인자 .contentProcessed에 준 함수는 그 데이터를 네트워크 쪽이 다 넘겨받았을 때 불린다. 그 안에서 다음 조각을 보내니, 연결에 쌓여 있는 클립보드 데이터는 늘 조각 하나뿐이다. 그 사이 8편의 같은 큐로 들어온 키 메시지는 다음 조각보다 먼저 나간다.

generation은 몇 번째 묶음인지 세는 번호다. 큰 로그를 보내는 도중에 새로 복사하면 번호가 올라가고, 옛 묶음의 남은 조각은 보내지 않는다. 윈도우 쪽은 8편의 _gate 잠금을 조각마다 놓았다 다시 잡아서 같은 효과를 낸다.

8편의 맥 안 시험에 장면을 하나 더 넣었다. 맥 쪽에서 3MB 텍스트를 보낸 직후 키 메시지를 보내고, 윈도우 쪽에서 키가 텍스트보다 먼저 도착했는지 로그 줄 순서로 본다.


받은 텍스트가 되돌아오지 않게

받은 텍스트를 내 클립보드에 넣으면, 그것도 클립보드가 바뀐 것이라 “복사함”으로 잡힌다. 그대로 두면 받은 걸 다시 보내고, 반대편은 그걸 또 받아 넣고, 끝없이 주고받게 된다.

1
2
3
4
5
6
7
8
9
// Clip.swift (KVMCore)
public mutating func shouldSend(_ text: String, token: Int, now: Double) -> Bool {
    guard token != writtenToken, text != lastReceived else { return false }
    if let lastSent, lastSent.text == text, now - lastSent.at < Self.repeatWindow { return false }
    lastSent = (text, now)
    // 내가 새로 복사했으니 받았던 텍스트는 잊는다. 그 뒤에 같은 텍스트를 다시 복사하면 보내야 한다.
    lastReceived = nil
    return true
}

token은 맥의 changeCount, 윈도우의 클립보드 순번이다. 받은 텍스트를 넣은 직후의 번호를 writtenToken으로 기억해 두고, 그 번호의 변경은 보내지 않는다.

규칙이유
내가 넣은 직후의 번호면 보내지 않는다받은 것을 넣은 변경이다
방금 받은 텍스트와 같으면 보내지 않는다번호가 한 번 더 바뀌어도 걸러지게(윈도우의 다른 앱이 클립보드를 건드리는 경우)
방금 보낸 텍스트와 같으면 1초 안에는 보내지 않는다한 번 복사에 알림을 여러 번 보내는 앱이 있다
내가 다른 걸 복사하면 받았던 텍스트는 잊는다그 뒤에 같은 텍스트를 다시 복사하면 보내야 한다
사진처럼 텍스트가 아닌 걸 복사해도 잊는다반대편에 사진만 있는데 텍스트를 못 보내는 일이 없게
연결이 끊기면 전부 잊는다다시 붙은 뒤에는 처음부터

처음에는 “마지막으로 받았거나 보낸 텍스트와 같으면 보내지 않는다”였다. 그러면 같은 문장을 한 번 보낸 뒤, 시간이 지나 다시 복사해도 넘어가지 않았다. 그래서 위처럼 바꿨다.


줄바꿈 맞추기

맥에서 복사한 여러 줄 텍스트를 윈도우 메모장에 붙이면 줄이 한 줄로 붙을 수 있고, 윈도우 텍스트를 맥에 붙이면 줄 끝에 ^M 같은 글자가 보일 수 있다.


줄바꿈 문자란?

줄을 바꾸라는 뜻의 눈에 안 보이는 글자다. 맥과 리눅스는 LF(\n) 하나를 쓰고, 윈도우는 CR과 LF 두 글자(\r\n)를 쓴다. 타자기에서 종이를 한 줄 올리는 것(LF)과 맨 앞으로 되돌리는 것(CR)을 따로 하던 시절의 흔적이다.

그래서 연결 위에서는 LF로 통일한다. 맥과 윈도우 모두 보낼 때 \r\n을 \n으로 바꾸고(Clip.toWire), 윈도우는 받은 텍스트를 넣을 때 \n을 \r\n으로 바꾼다(Clip.ToWindows). 맥은 받은 그대로 넣는다.


옛 버전과 섞일 때

clip은 새 메시지라 옛 버전은 모른다. 지금까지 MyKVM은 모르는 메시지를 받으면 연결이 깨진 것으로 보고 끊었다. 그래서 새 맥 앱이 옛 윈도우 exe에 clip을 보내면, 복사할 때마다 연결이 끊긴다. 이번 기능은 맥 앱과 윈도우 exe를 꼭 같이 바꿔야 한다.

앞으로 같은 일이 없게 이번부터는 규칙을 바꿨다. 암호를 풀고 인증까지 통과한 메시지인데 종류("t")만 모르면 끊지 않고 버린다. 형식이 깨진 메시지는 지금처럼 끊고, 인증 전 메시지도 지금처럼 엄격하게 본다.

1
2
3
4
5
6
7
8
9
10
11
// ServerSession.swift (KVMCore)
guard var channel, let plain = try? channel.open(payload) else { return close("bad encrypted frame") }
self.channel = channel
do {
    return [.received(try Message.decode(plain))]
} catch MessageError.unknownType {
    // 인증까지 통과했는데 종류만 모른다. 새 버전 맥/윈도우가 보낸 것일 수 있어 버린다.
    return []
} catch {
    return close("bad encrypted frame")
}

모르는 종류는 Message.decode가 MessageError.unknownType이라는 따로 된 오류로 알린다. 윈도우 쪽은 JSON의 "t"를 먼저 읽어, 등록된 종류 목록(6편의 JsonDerivedType)에 없으면 UnknownMessageTypeException을 던진다.


맥에 MyKVM이 두 개 뜬 문제

새 맥 앱과 새 윈도우 exe로 바꿔서 실제로 써 보니, 처음에는 클립보드가 전혀 넘어가지 않았다. 맥 로그를 보니 윈도우에서 복사한 시각마다 연결이 끊겼다가 다시 붙어 있었다.

원인은 맥에 MyKVM이 두 개 떠 있던 것이었다. 전날 켠 옛 앱이 남아서 연결 문(TCP 52810)과 윈도우 연결을 쥐고 있었고, 새로 켠 앱은 문이 차 있어서 윈도우와 연결하지 못했다. 윈도우의 새 exe가 보낸 clip을 옛 앱이 모르는 메시지로 보고 끊은 것이다. 옛 앱을 끄자 새 앱이 문을 넘겨받았고, 그 뒤로 양쪽 다 잘 넘어갔다.

같은 일이 다시 없게, 맥 앱은 켤 때 먼저 떠 있던 MyKVM을 끄게 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SingleInstance.swift (MyKVMMac)
static func quitOlderInstances() {
    guard let bundleID = Bundle.main.bundleIdentifier else { return }
    let me = ProcessInfo.processInfo.processIdentifier
    let older = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).filter { $0.processIdentifier != me }
    guard !older.isEmpty else { return }
    older.forEach { $0.terminate() }
    let pids = older.map(\.processIdentifier)
    DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
        for pid in pids {
            guard let app = NSRunningApplication(processIdentifier: pid), !app.isTerminated else { continue }
            app.forceTerminate()
        }
    }
}

NSRunningApplication으로 먼저 뜬 앱 끄기

  1. runningApplications(withBundleIdentifier:)는 같은 번들 ID(앱 이름표, 여기서는 com.harold.MyKVM)로 떠 있는 앱을 모두 돌려준다. 나 자신도 들어 있어서 내 프로세스 번호는 뺀다.
  2. terminate()는 그 앱에 “종료해 줘”를 보낸다. 메뉴에서 종료를 누른 것과 같아서, 옛 앱도 눌린 키를 떼는 정리를 하고 끝난다.
  3. 3초 뒤에도 isTerminated가 아니면 forceTerminate()로 강제로 끝낸다.

새로 켠 쪽이 늘 이긴다. 옛 앱이 문을 비우면 8편의 서버가 1초 안에 다시 문을 연다. Xcode로 새 빌드를 띄울 때마다 옛 것이 정리되니, 맥 앱과 윈도우 exe를 같이 바꾸는 이번 같은 경우에도 버전이 섞이지 않는다.

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