StreamDeck (11) 아이폰 Face ID로 맥 잠금 풀기
이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.
잠긴 맥 앞에 앉을 때마다 맥 비밀번호를 친다. 애플워치가 있으면 손목으로 풀리지만 없으니, 늘 옆에 있는 아이폰으로 풀고 싶었다. 아이폰 TouchDeck 화면 위에 “맥 잠금 풀기” 버튼이 뜨고, 누르면 Face ID를 거쳐 맥이 풀리는 것이다.
정한 조건은 셋이다.
- Face ID로 확인하고, 안 되면 아이폰 암호로도 된다.
- 가까이 있을 때만 된다. 같은 와이파이나 기기끼리 직접 연결이면 되고, Tailscale(9편)처럼 밖에서 붙은 연결로는 안 된다.
- 다른 버튼이나 화면 보기와 똑같이, 허락받은 연결의 봉투(6편) 안에서만 오간다.
맥 잠금을 푸는 방법 찾기
macOS에는 다른 앱이 화면 잠금을 풀어 주는 공식 기능이 없다. 애플워치 잠금 해제는 애플만 쓰는 기능이다. 남는 방법은 하나다. 맥 앱이 잠금 화면 비밀번호 칸에 비밀번호를 대신 쳐 주는 것이다. 6편의 숫자 키패드와 8편의 입력칸이 쓰는 “키를 누른 것처럼 보내기”와 같다.
첫 로그인 화면과 화면 잠금은 어떻게 다른가
맥에는 비밀번호를 묻는 화면이 두 가지 있다. 하나는 전원을 켰을 때 나오는 첫 로그인 화면이다. FileVault(디스크 암호화)가 켜져 있으면 이 화면은 디스크를 풀기 전에 뜬다. 그래서 그때는 내 앱이 하나도 돌지 않는다. 다른 하나는 로그인해서 쓰다가 잠근 화면이다. 이때는 TouchDeck이 뒤에서 계속 돌고 있다.
그래서 아이폰으로 풀 수 있는 건 두 번째뿐이다. 맥이 잠든 동안에도 네트워크가 멈춰 연결이 없으니 풀 수 없다. 맥 키보드를 눌러 깨우면 몇 초 안에 다시 붙는다.
잠금 화면이 앱의 키 입력을 받는지 시험하기
잠금 화면은 보안 때문에 다른 프로그램의 입력을 막아 둘 수도 있다. 막혀 있으면 이 방법은 처음부터 안 된다. 그래서 만들기 전에 버릴 코드로 시험부터 했다. 맥 메뉴에 버튼을 하나 두고, 누르면 5초 뒤 키 번호로 test, 1초 뒤 글자 그대로(keyboardSetUnicodeString, 8편) xy를 치게 했다. 엔터는 누르지 않아서 틀린 횟수로 세지지 않는다. 그 사이에 맥을 잠그고 비밀번호 칸을 지켜봤다.
| 비밀번호 칸 | 뜻 |
|---|---|
| 점 6개 | 두 방식 다 된다 |
| 점 4개 | 키 번호 방식만 된다 |
| 점 2개 | 글자 그대로 방식만 된다 |
| 점 없음 | 잠금 화면이 막는다 |
결과는 점 6개였다(macOS 27.0.1). 두 방식 다 잠금 화면에 들어갔다. 그런데 기록에 더 중요한 게 남아 있었다.
1
2
3
4
5
6
7
03:23:09 start accessibility=true locked=false
03:23:14 key codes: locked=false
03:23:16 characters: locked=false
// 생략
03:23:33 start accessibility=true locked=false
03:23:38 key codes: locked=true
03:23:39 characters: locked=true
앞의 두 번은 잠그기 전에 5초가 지나서, locked=false인 채로 그때 앞에 떠 있던 앱에 testxy가 들어갔다. 비밀번호를 칠 때 이런 일이 생기면 메신저나 검색창에 맥 비밀번호가 그대로 들어간다. 그래서 정식 기능에서는 “치기 직전에 정말 잠겨 있는지 다시 확인하기”를 가장 먼저 정했다.
비밀번호는 아이폰 키체인에만
맥 비밀번호를 어디에 둘지가 다음이다. 맥에 저장해 두고 아이폰은 “풀어 줘”만 보내는 방법도 있다. 그러면 허락 토큰(5편)을 누가 훔쳤을 때 Face ID 없이도 맥을 풀 수 있다. 그래서 비밀번호는 아이폰에만, 꺼낼 때마다 Face ID를 거치는 칸에 두기로 했다. 맥에는 어디에도 저장하지 않는다.
키체인과 접근 제어란?
키체인은 애플 기기가 비밀번호나 토큰 같은 작은 비밀을 암호화해서 보관하는 곳이다. 5편에서 허락 토큰도 여기에 뒀다. 키체인에 넣는 항목마다 “언제 꺼낼 수 있는지”를 붙일 수 있는데, 이걸 접근 제어라고 한다. 은행 금고의 칸마다 열쇠를 다르게 다는 것과 같다.
맥 비밀번호 칸에는 두 가지를 걸었다. 꺼낼 때마다 본인 확인(Face ID, 안 되면 아이폰 암호)을 거치게 하고, 아이폰에 암호가 설정돼 있는 동안 이 기기에서만 쓰게 했다. iCloud 키체인으로 다른 기기에 퍼지지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static func save(_ password: String) -> String? {
delete()
guard let access = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .userPresence, nil) else {
return "비밀번호를 저장하지 못했어요"
}
var q = baseQuery
q[kSecValueData as String] = Data(password.utf8)
q[kSecAttrAccessControl as String] = access
let status = SecItemAdd(q as CFDictionary, nil)
guard status == errSecSuccess else {
print("[unlock] save failed:", status)
return "아이폰에 암호가 설정돼 있어야 저장할 수 있어요"
}
return nil
}
SecAccessControl로 꺼낼 때마다 Face ID 묻기
저장은 이렇게 한다. baseQuery는 “어떤 종류의 항목, 어느 앱 이름, 어느 칸”을 정한 사전이다.
SecAccessControlCreateWithFlags(_:_:_:_:)로 이 칸의 접근 제어를 만든다. 둘째 인자kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly는 아이폰에 암호가 있을 때만, 이 기기에서만 쓸 수 있다는 뜻이다. 아이폰 암호를 끄면 이 칸은 지워진다. 셋째 인자.userPresence는 꺼낼 때마다 Face ID나 Touch ID를 묻고, 안 되면 아이폰 암호로 넘어가라는 뜻이다. Face ID를 새로 등록해도 이 칸은 그대로 남는다.- 저장할 값(
kSecValueData)과 만든 접근 제어(kSecAttrAccessControl)를 사전에 넣는다. SecItemAdd(_:_:)로 키체인에 넣는다. 결과는 숫자(OSStatus)로 오고,errSecSuccess가 성공이다. 넣기 전에SecItemDelete(_:)로 같은 칸을 지워서, 비밀번호를 바꿀 때 덮어쓴다.
Face ID를 쓰는 앱은 Info.plist에 NSFaceIDUsageDescription(왜 쓰는지 적은 글)이 꼭 있어야 한다. 없으면 Face ID를 묻는 순간 앱이 꺼진다. “맥 잠금을 풀 때 본인인지 확인해요”로 넣었다.
꺼낼 때는 시스템이 알아서 Face ID 창을 띄운다.
1
2
3
4
5
6
7
8
9
10
11
12
static func read(reason: String) async -> String? {
await Task.detached {
let context = LAContext()
context.localizedReason = reason
var q = baseQuery
q[kSecReturnData as String] = true
q[kSecUseAuthenticationContext as String] = context
var result: AnyObject?
guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}.value
}
LAContext로 Face ID 창에 이유 적기
LAContext는 Face ID 같은 본인 확인 한 번을 다루는 객체다. localizedReason에 넣은 글(“맥 잠금을 풀려면 확인해 주세요”)이 Face ID 창에 보인다. 이걸 kSecUseAuthenticationContext로 키체인 요청에 붙이고 SecItemCopyMatching(_:_:)로 꺼낸다. 본인 확인이 끝날 때까지 이 함수가 기다리기 때문에, 화면이 멈추지 않게 Task.detached(2편)로 메인 밖에서 부른다. 취소하거나 실패하면 nil이다.
버튼을 처음 누를 때는 저장된 비밀번호가 있는지만 알고 싶다. 이때 Face ID 창이 뜨면 안 되니, interactionNotAllowed를 켠 LAContext로 물어본다. 칸이 있으면 바로 성공하거나 “확인이 필요한데 창을 띄울 수 없음”(errSecInteractionNotAllowed)이 오는데, 둘 다 “있음”으로 친다. 칸이 없으면 errSecItemNotFound다.
가까이 있을 때만
맥은 연결을 받을 때 상대 주소를 안다. 그 주소가 Tailscale 주소면 거절한다. 같은 와이파이의 사설 주소(192.168…)나 기기끼리 직접 연결의 주소(fe80::…%awdl0)는 받는다. 아이폰도 원격 주소로 붙었으면 버튼을 안 띄우지만, 정하는 건 맥이다.
100.64.0.0/10 같은 주소 범위란?
IPv4 주소는 32비트 숫자를 8비트씩 네 칸으로 끊어 쓴 것이다. 100.64.0.0/10처럼 뒤에 /숫자를 붙이면 “앞의 그 비트 수만 같으면 이 범위”라는 뜻이다. Tailscale은 이 범위의 주소를 기기마다 준다. 원래 통신사들이 집 공유기 바깥에서 쓰라고 떼어 둔 범위라 집 와이파이 주소와 겹치지 않는다.
IPv6는 fd7a:115c:a1e0::/48이라 앞 48비트(6바이트)가 같은지 본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static func isNearby(_ host: NWEndpoint.Host) -> Bool {
switch host {
case .ipv4(let address):
return !isTailscale(ipv4: [UInt8](address.rawValue))
case .ipv6(let address):
let bytes = [UInt8](address.rawValue)
// IPv4를 IPv6 모양으로 보여 주는 경우(::ffff:a.b.c.d)
if bytes.count == 16, bytes[0..<10].allSatisfy({ $0 == 0 }), bytes[10] == 0xff, bytes[11] == 0xff {
return !isTailscale(ipv4: Array(bytes[12..<16]))
}
return !(bytes.count == 16 && bytes[0..<6].elementsEqual([0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0]))
default:
return false
}
}
private static func isTailscale(ipv4 bytes: [UInt8]) -> Bool {
bytes.count == 4 && bytes[0] == 100 && bytes[1] & 0xC0 == 64
}
NWEndpoint.Host에서 주소 바이트 꺼내기
NWEndpoint.Host는 연결 상대의 주소다. IPv4Address, IPv6Address, 이름 셋 중 하나로 온다. 주소의 rawValue는 주소를 바이트로 담은 Data라, [UInt8](…)로 바꿔 한 칸씩 본다.
- IPv4는 첫 바이트가 100이고, 둘째 바이트의 앞 두 비트가
01인지 본다.& 0xC0은 앞 두 비트만 남기는 계산이고, 그 값이 64(01000000)면 64~127이다. - IPv6는 앞 6바이트를
elementsEqual(_:)로fd7a:115c:a1e0과 비교한다. - 맥이 IPv4 연결을
::ffff:100.64.0.1처럼 IPv6 모양으로 보여 줄 때가 있다. 그때는 뒤 4바이트를 IPv4로 다시 본다. - 이름으로 된 주소처럼 판단할 수 없으면 가깝지 않은 것으로 본다. 모르면 막는 쪽이다.
맥에서는 연결의 endpoint가 .hostPort(host, port)일 때만 이 판단을 하고, 다른 모양이면 역시 가깝지 않은 것으로 본다.
치기 직전에 다시 확인하기
맥이 비밀번호를 받으면 아래를 차례로 본다. 하나라도 아니면 치지 않고 이유를 돌려준다.
| 순서 | 보는 것 | 아니면 |
|---|---|---|
| 1 | 맥 메뉴에서 이 기기의 “잠금 풀기 허용”이 켜져 있다 | 허용을 켜 달라 |
| 2 | 가까운 연결이다 | 같은 와이파이에서만 된다 |
| 3 | 최근에 여러 번 틀리지 않았다 | 5분 쉰다 |
| 4 | 지금 잠겨 있다 | 이미 풀려 있다 |
다 통과하면 비밀번호를 8편과 같은 방식으로 20글자씩 묶어 친다. 시험에서 본 일 때문에, 치기 직전만이 아니라 묶음 사이마다 다시 확인한다. 중간에 잠금이 아니게 되면 남은 글자와 엔터를 치지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
func type(_ password: String, done: @escaping @Sendable (Bool) -> Void) {
let chunks = TextInput.chunks(password)
queue.async { [post, isLocked, pause] in
for (i, chunk) in chunks.enumerated() {
if i > 0 { pause() }
guard isLocked() else { return done(false) }
post(.text(chunk))
}
guard isLocked() else { return done(false) }
post(.key(CGKeyCode(kVK_Return), []))
done(true)
}
}
치는 일과 잠금 확인을 밖에서 넣게 해 둬서, 가짜 확인으로 “첫 묶음 뒤에 풀림”을 만들어 테스트했다.
1
2
3
4
5
6
7
8
9
10
@Test func stopsWhenUnlockedMidway() {
let log = StepLog()
let checks = Counter()
// 첫 묶음 전에는 잠김, 두 번째 묶음 전부터 안 잠김
let unlocker = MacUnlocker(post: { log.append($0) }, isLocked: { checks.next() == 0 }, pause: {})
let finished = Expectation()
unlocker.type(String(repeating: "a", count: 30)) { finished.fulfill($0) }
#expect(finished.wait() == false)
#expect(log.steps == [.text(String(repeating: "a", count: 20))])
}
엔터까지 치면 2초 기다렸다가 다시 본다. 아직 잠겨 있으면 비밀번호가 틀린 것이고, 풀렸으면 성공이다. 성공하면 맥 메뉴에 “마지막 잠금 풀기: 아이폰 이름, 시각”을 남긴다.
화면 보호기는 잠금으로 치지 않기
7편에서 만든 잠금 확인은 “화면 잠김” 또는 “화면 보호기가 돌고 있음”이었다. 화면 보기를 멈출 때는 그게 맞다. 그런데 잠금 풀기에 그대로 쓰면 문제가 있었다. 암호 없이 깨어나는 화면 보호기가 돌 때 비밀번호를 치면, 첫 입력에 화면 보호기가 걷히고 나머지 글자가 앞에 떠 있는 앱에 들어간다. 전체를 다시 검토하다가 찾은 것이다.
그래서 잠금 풀기는 시스템이 “화면 잠김”이라고 할 때만 친다.
1
2
3
4
5
6
7
static var isPasswordLocked: Bool {
passwordLocked(session: CGSessionCopyCurrentDictionary() as? [String: Any])
}
static func passwordLocked(session: [String: Any]?) -> Bool {
(session?["CGSSessionScreenIsLocked"] as? NSNumber)?.boolValue == true
}
CGSessionCopyCurrentDictionary로 잠금 상태 읽기
CGSessionCopyCurrentDictionary()는 지금 로그인한 사용자 세션의 상태를 사전으로 준다. 그 안의 CGSSessionScreenIsLocked가 화면이 잠겼는지다. 이 키 이름은 Apple 문서에 따로 적혀 있지 않아서, 사전을 받는 부분과 판단하는 부분을 나누고 판단만 테스트했다.
잠김과 풀림이 바뀌는 순간은 DistributedNotificationCenter로 받는다. 앱끼리 주고받는 알림 통로이고, 시스템이 잠글 때 com.apple.screenIsLocked, 풀 때 com.apple.screenIsUnlocked를 보낸다. default()로 꺼내 9편과 같은 addObserver(forName:object:queue:using:)로 받는다. 받을 때마다 붙어 있는 기기에 “잠김 여부와 이 기기가 풀 수 있는지”를 보낸다. 아이폰은 이 값으로만 버튼을 띄운다.
틀린 비밀번호와 찍어 보기 막기
맥 비밀번호를 바꾸고 아이폰에서 안 바꿨으면 계속 틀린다. 누가 아이폰을 들고 비밀번호를 바꿔 가며 찍어 볼 수도 있다(5편의 무차별 대입). 그래서 기기마다 5분 안에 3번 틀리면 5분 동안 받지 않는다.
1
2
3
4
5
6
7
public mutating func recordFailure(now: Date) {
failures = failures.filter { now.timeIntervalSince($0) < Self.window } + [now]
if failures.count >= Self.maxFailures {
blockedUntil = now.addingTimeInterval(Self.window)
failures = []
}
}
최근 300초 안의 실패만 남기고 이번 실패를 더한다. 3번이 되면 그때부터 300초 동안 막고 횟수를 비운다. 성공하면 처음부터 센다. 시각을 인자로 받아서 “20초 뒤”, “301초 뒤”를 테스트에서 바로 만들 수 있다.
잠겨 있는 동안은 다른 기능 막기
실기기에서 풀기는 잘 됐는데, 잠겨 있는 동안 화면 보기와 다른 버튼도 그대로 눌렸다. 그대로 두면 잠긴 맥 뒤에서 앱이 열리거나 터미널 명령이 돌고, 키패드로 친 숫자가 잠금 화면 비밀번호 칸에 들어갈 수 있다. 그래서 잠겨 있는 동안은 잠금 풀기 말고는 아무것도 하지 않게 했다.
아이폰 화면만 흐리게 막으면, 예전 버전 앱이나 이미 열려 있던 화면에서 온 요청은 그대로 실행된다. 그래서 맥에서도 막는다. 무엇을 막을지는 DeckKit에 한 곳으로 모았다.
1
2
3
4
5
6
7
8
9
10
11
public enum LockGate {
public static func allowsWhileLocked(_ message: ClientMessage) -> Bool {
switch message {
case .press, .typeKey, .typeText, .pressKey, .pointer, .setButton, .clearButton,
.setVolume, .volumeKey, .listFolder, .startScreen, .screenOptions:
false
case .unlock, .getApps, .listDisplays, .stopScreen, .requestKeyframe, .hello, .pair, .auth:
true
}
}
}
default를 두지 않았다. 나중에 메시지 종류를 하나 더하면 이 switch가 컴파일되지 않아서, 그 메시지를 잠겨 있을 때 받을지 꼭 정하게 된다. 깜빡하고 잠긴 맥에 무언가를 하는 메시지가 생기지 않게 하려는 것이다.
아이폰은 맥이 잠기면 상태 줄을 “맥이 잠겨 있어요. 잠금을 풀면 다시 쓸 수 있어요”로 바꾸고, 스트림덱 버튼, 키패드, 화면 탭, 볼륨을 연결이 끊겼을 때처럼 흐리게 막는다. “맥 잠금 풀기” 버튼만 눌린다.
화면 위에 겹쳐 뜨는 버튼
잠겨 있으면 아래 화면이 흐려지니, 버튼은 그 위에 겹쳐 띄웠다. 스트림덱, 키패드, 맥 화면 어디서든 아래 가운데에 뜬다.
맥이 잠긴 동안의 아이폰 기호 화면이다. 상태 줄 점이 주황으로 바뀌었고, 키와 볼륨이 흐려져 눌리지 않는다. 아래 가운데의 초록 “맥 잠금 풀기”만 눌린다.
버튼을 누르고 Face ID를 통과한 뒤 맥의 답을 기다리는 동안이다. 버튼 글이 “푸는 중”으로 바뀌고, 답이 올 때까지 다시 눌리지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
Button {
Task { await client.unlockMac() }
} label: {
Label(client.unlockStatus == .working ? "푸는 중" : "맥 잠금 풀기", systemImage: "lock.open.fill")
// 생략
}
.buttonStyle(KeyPressStyle())
.disabled(client.unlockStatus == .working)
// 맥 비밀번호를 바꿨을 때
.contextMenu {
Button("비밀번호 바꾸기", systemImage: "key") { client.needsMacPassword = true }
Button("비밀번호 지우기", systemImage: "trash", role: .destructive) { client.deleteMacPassword() }
}
overlay와 contextMenu로 겹쳐 띄우고 길게 누르기
overlay(alignment:content:)는 뷰 위에 다른 뷰를 겹쳐 그린다.alignment: .bottom으로 아래 가운데에 붙였다. 앱 맨 바깥 화면에 붙여서 탭을 바꿔도 남는다.contextMenu(menuItems:)는 길게 누르면 뜨는 메뉴다. 맥 비밀번호를 바꿨을 때 여기서 바꾸거나 지운다.- 맥 화면 보기는
fullScreenCover(item:onDismiss:content:)(화면 전체를 덮는 새 화면)로 열려서, 바깥에 붙인 버튼이 그 아래에 가려진다. 그래서 보기 화면 안에도 같은 버튼을 따로 붙였다. 둘 중 하나만 보여서 겹치지 않는다.
처음 누를 때 저장된 비밀번호가 없으면 비밀번호를 넣는 시트가 뜬다. 입력칸은 SecureField(친 글자가 점으로 보이는 칸)다. 저장하면 바로 Face ID로 이어져 맥이 풀린다.
비밀번호를 보낸 뒤 8초 안에 맥이 답하지 않으면 연결이 끊긴 것으로 보고 다시 연결한다. 아래 “풀린 뒤에도 남던 버튼”에서 이렇게 바꾼 까닭을 적었다.
풀린 뒤에도 남던 버튼
아이폰과 아이패드를 같이 켜 두고 아이폰으로 맥을 풀었다. 맥은 풀렸고 아이패드의 버튼도 사라졌는데, 아이폰은 상태 점이 주황인 채로 버튼이 남아 있었다.
아이폰 숫자 화면이다. 맥은 이미 풀렸는데 키가 여전히 흐리고, 버튼을 다시 누르면 맥이 “맥이 이미 풀려 있어요”로 답했다. 맥은 풀렸다고 알고 있고, 아이폰만 아직 잠겼다고 알고 있는 것이다.
다른 때에는 “맥에서 답이 없어요”가 떴다. 보낸 뒤 8초 안에 답이 오지 않았다는 뜻이다.
알림이 온 순간과 시스템 값
맥은 “풀림” 알림을 받으면 그 자리에서 ScreenLock.isPasswordLocked를 다시 읽어 기기들에 보냈다. 그런데 알림이 온 바로 그 순간에는 시스템 세션 값이 아직 “잠김”으로 남아 있을 수 있다. 그러면 풀렸는데도 “잠김”을 보내고, 아이폰은 그 값을 믿고 버튼을 계속 띄운다. 아이패드는 그 뒤 다시 붙으면서 새 값을 받아 버튼이 사라진 것으로 보인다.
그래서 세 군데를 고쳤다.
- 맥은 알림이 오면 다시 읽지 않고, 알림 이름(“잠김”인지 “풀림”인지)을 그대로 보낸다.
- 맥은 잠금 풀기에 성공하면, 붙어 있는 모든 기기에 “풀림”을 한 번 더 보낸다.
- 아이폰은 맥이 “풀었음”이나 “이미 풀려 있음”으로 답하면 스스로 잠김 표시를 지운다. 알림 하나를 놓쳐도 버튼이 남지 않는다.
1
2
3
4
5
6
7
8
9
/// 잠김/풀림 알림이 온 순간에는 CGSession 값이 아직 바뀌기 전일 수 있다(풀렸는데 잠김으로 읽힘).
/// 그래서 알림 이름으로 정한다. 모르는 알림이면 nil
static func locked(afterNotification name: String) -> Bool? {
switch name {
case lockedNotification: true
case unlockedNotification: false
default: nil
}
}
알림 이름에서 상태를 정하는 부분은 테스트로 박았다. 알림이 온 순간의 시스템 값이 정말 늦게 바뀌는지는 맥을 잠갔다 푸는 실기기에서만 보여서, 고친 뒤에 다시 확인해야 한다. 아직 확인 못 했다.
풀 수 없는 기기에 남는 잠김
아이폰은 Tailscale로, 아이패드는 와이파이로 붙여 둔 채 맥을 잠갔다. 둘 다 흐려지는 것까지는 같았다. 아이패드에는 잠금 풀기 버튼이 떴고, 아이폰은 원격이라 버튼이 뜨지 않았다. 여기서 아이패드로 맥을 풀었더니, 아이패드는 다시 밝아졌는데 아이폰은 흐린 채로 남았다. 아이폰에서 누를 수 있는 게 하나도 없어서, 앱을 껐다 켜야 다시 붙으면서 돌아왔다.
아이폰이 “풀림” 소식을 받지 못한 것이다. 앞 절의 “알림이 온 순간의 시스템 값” 때문일 수도 있고, 원격 연결이 그 사이 끊겼을 수도 있다. 5편의 TCP keepalive는 끊긴 연결을 11초쯤 안에 알아채지만, 아이폰 화면이 꺼지거나 앱이 뒤로 가면 iOS가 앱을 멈춰서 그동안은 물어보지도 못한다. 어느 쪽이든 문제는 “이 기기에서는 풀 수도, 다시 붙을 수도 없는 상태”가 생긴다는 것이다.
그래서 맥이 잠겨 있다고 알고 있는데 이 기기에서는 풀 수 없을 때(원격이거나 맥 메뉴에서 허용을 안 했을 때), 화면 아래에 안내와 “다시 연결” 버튼을 띄운다. 누르면 앱을 껐다 켜는 것처럼 연결을 끊고 처음부터 다시 찾는다. 다시 붙으면 맥이 지금 잠금 상태를 새로 보내 준다.
1
2
3
4
5
6
7
/// 맥이 잠겼다고 알고 있는데 이 기기에서는 풀 수 없을 때(원격, 허용 꺼짐) 보여 줄 안내.
/// 다른 기기로 풀었는데 소식을 못 받으면 아무것도 못 누르니, 이때도 다시 연결할 수 있게 한다
var lockedHere: String? {
guard isConnected, canUnlock, macLocked, !showsUnlock else { return nil }
if isRemote { return "맥이 잠겨 있어요. 밖에서는 풀 수 없어요" }
return "맥이 잠겨 있어요. 맥 메뉴에서 잠금 풀기를 허용하면 여기서 풀 수 있어요"
}
잠금 풀기 버튼이 뜨는 기기에도 같은 길을 뒀다. 비밀번호를 보낸 뒤 8초 안에 맥이 답하지 않으면 연결이 끊긴 것으로 보고 저절로 다시 붙는다. 실패 문구 옆과 버튼을 길게 눌러 여는 메뉴에도 “다시 연결”이 있다.
1
2
3
4
5
6
Task {
try? await Task.sleep(for: .seconds(8))
guard unlockStatus == .working else { return }
reconnectNow()
unlockStatus = .failed("맥에서 답이 없어 다시 연결하는 중이에요")
}
와이파이를 꺼도 뜨던 잠금 풀기
Tailscale로 붙었을 때 잠금 풀기 버튼이 안 뜨는지 보려고 아이폰 와이파이를 껐다. 그런데 버튼이 떴다. 상태 줄 이름표는 “기타 연결”이라, 어떤 길로 붙었는지 알 수 없었다. 맥에서 지금 붙어 있는 연결을 봤다.
1
2
3
$ netstat -an -p tcp | grep 52801
tcp6 fe80::2c52:abff:.52801 fe80::308e:f6ff:.49498 ESTABLISHED
tcp6 fe80::4b:6527:91.52801 fe80::c01:9ab2:9.58646 ESTABLISHED
| 맥 쪽 주소 | 맥 쪽 장치 | 누구 |
|---|---|---|
fe80::2c52:abff:… | awdl0, 기기끼리 직접 연결 | 아이폰 |
fe80::4b:6527:… | en8, 유선 랜 | 아이패드 |
아이패드 앱을 끄자 둘째 줄이 사라져서, 첫째 줄이 아이폰인 걸 알았다. 아이폰은 Tailscale이 아니라 기기끼리 직접 연결(1편)로 붙어 있었다. 아이폰에서 와이파이와 블루투스를 꺼도, 이미 맺어 둔 맥과의 직접 연결은 그대로 남아 있었다. 집 와이파이 네트워크에서만 빠지고 직접 연결용 장치는 살아 있던 것으로 보인다. 직접 연결은 몇 미터 안에서만 되니 “가까이 있을 때”에 들어가고, 그래서 버튼이 뜬 건 맞는 동작이었다.
헷갈린 건 이름표였다. 아이폰은 이번에도 자기 쪽 장치를 엉뚱하게 알려 줘서 “직접 연결”이 아니라 “기타 연결”로 보였다. 반면 맥은 연결이 어느 장치(awdl0)로 들어왔는지, 상대 주소가 Tailscale 범위인지를 정확히 안다. 그래서 이름표는 맥이 정하게 바꿨다. 맥이 판단한 길을 route 메시지로 보내고, 아이폰은 그 값을 이름표로 쓴다. 예전 맥 앱과 붙으면 지금처럼 아이폰이 스스로 판단한다(기능 번호 5).
처음에는 맥이 연결의 “쓸 수 있는 장치 목록”(availableInterfaces, 1편) 첫째를 보고 정했다. 그랬더니 이번에는 아이패드가 “직접 연결”로 떴다. netstat으로는 아이패드가 유선 랜(en8)으로 들어와 있었다. 맥에서는 랜으로 들어온 연결도 그 목록의 첫째가 awdl0로 나왔던 것이다. 목록은 “쓸 수 있는 장치”일 뿐, 이 연결이 실제로 탄 장치가 아니었다.
그래서 상대 주소를 본다. 근처 주소(fe80::…)에는 실제로 탄 장치 이름이 %en8, %awdl0처럼 붙어 있고, netstat에 보이는 것도 이 값이다. 맥은 자기가 확실히 아는 것만 정한다.
| 상대 주소 | 이름표 |
|---|---|
| Tailscale 범위(100.64.0.0/10, fd7a:115c:a1e0::/48) | 원격(VPN) |
장치가 awdl, llw | 직접 연결 |
| 장치에 근처 전용 주소(169.254, fe80)만 있다 | 유선(케이블) |
| 그 밖 | 같은 네트워크 |
맥이 랜선으로 공유기에 붙어 있어도, 와이파이로 붙은 아이패드에게는 같은 네트워크다. 그래서 와이파이인지 유선인지는 기기가 자기 연결로 정한다.
셋째 줄은 와이파이와 블루투스를 다 끄고 케이블만 꽂은 아이폰에서 나왔다. 그래도 붙길래 맥을 보니 en7이라는 장치가 새로 생겨 있었다.
1
en7: … inet6 fe80::1839:7bef:7b5d:e3f9%en7 … inet 169.254.116.8 …
아이폰을 케이블로 꽂으면 맥과 아이폰 사이에만 있는 작은 네트워크가 생기고, TouchDeck은 Bonjour로 그 선까지 찾아 붙는다. 이 장치에는 공유기가 주는 집 네트워크 주소가 없고, 169.254로 시작하는 근처 전용 주소만 있다. 그래서 “근처 전용 주소만 있는 장치로 들어온 연결”은 케이블로 바로 붙은 것으로 본다. 케이블을 뽑으면 그 연결이 끊기고, 와이파이나 직접 연결로 다시 붙으면서 이름표도 바뀐다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public init(macSideRemote remote: NWEndpoint.Host?, link: String?, linkIsLocalOnly: Bool) {
if let remote, UnlockPolicy.isTailscale(remote) {
self = .vpn
} else if let link, link.hasPrefix("awdl") || link.hasPrefix("llw") {
self = .direct
} else if link != nil, linkIsLocalOnly {
self = .wired
} else {
self = .sameWiFi
}
}
/// 기기가 보여 줄 길. 직접 연결과 원격은 맥 판단을, 같은 네트워크면 기기 자신의 유선 판단을 쓴다
public static func shown(mac: ConnectionRoute?, local: ConnectionRoute?) -> ConnectionRoute? {
guard let mac else { return local }
if mac == .sameWiFi, local == .wired { return .wired }
return mac
}
IPv6Address.interface로 실제로 탄 장치 읽기
맥은 연결 상대 주소가 IPv6면 interface로 그 주소에 붙은 장치를 꺼내고, 그 name(awdl0, en8)을 본다. 근처 주소는 장치마다 따로 있어서 주소에 장치가 꼭 붙는다. IPv4 주소(192.168…)에는 장치가 없으니 같은 네트워크로 본다. 장치에 근처 전용 주소만 있는지는 맥 쪽에서 getifaddrs(맥의 모든 장치와 주소 목록을 주는 C 함수)로 그 장치의 주소를 훑어 본다.
맥은 연결이 허락받은 직후와, 길이 바뀔 때(pathUpdateHandler, 1편)마다 다시 보낸다. 직접 연결로 붙었다가 와이파이로 바뀌어도 이름표가 따라간다. 맥이 정한 이름표가 실제 아이폰에서 “직접 연결”로 보이는지는 아직 확인 못 했다.
좁은 화면의 상태 줄
아이폰 세로 화면에서는 상태 줄 글이 들어갈 자리가 거의 없다. 화면 고르기와 “밖에서 붙기” 버튼, 경로 표시가 자리를 차지해서, “맥에 연결됨” 같은 글이 “…”로 잘렸다.
바꾸기 전이다. 초록 점 옆의 글이 “…”로만 보이고, 경로 표시도 글자가 잘려 와이파이 아이콘만 남았다.
그래서 아이폰 세로처럼 좁은 화면에서 연결돼 있을 때는 글을 빼고 아이콘만 보이게 했다. 점의 색(초록은 연결, 주황은 잠김), 경로 아이콘, 지구본만 남는다. 맥이 잠기면 점 옆에 자물쇠 아이콘이 붙는다.
바꾼 뒤다. 초록 점, 와이파이 아이콘, 지구본만 보인다.
맥을 찾는 중이거나 권한이 꺼진 것처럼 무엇을 해야 하는지 알려야 할 때는 글을 남겼다. 대신 두 줄까지 쓸 수 있게 했다.
맥을 찾는 중이다. “같은 와이파이에서 맥을 찾는 중…”이 두 줄로 보이고, 그래도 넘치는 뒤쪽은 잘린다. 좁은 화면이라 다 보이지는 않지만, 무슨 상태인지는 알 수 있다.
1
2
/// 연결된 평소 상태(잠김 포함)는 아이콘만. 찾는 중이나 오류는 무엇을 할지 알려야 해서 글을 남긴다
private var iconOnly: Bool { compact && connected }
horizontalSizeClass로 좁은 화면 알기
horizontalSizeClass는 지금 화면의 가로가 좁은지(.compact) 넓은지(.regular)를 알려 주는 환경 값이다. @Environment(\.horizontalSizeClass)로 꺼낸다. 아이폰 세로는 .compact, 아이패드는 세로든 가로든 .regular라서 아이패드 화면은 그대로다. 아이콘만 보일 때도 VoiceOver는 상태 글과 경로 이름을 읽게 accessibilityLabel을 붙였다.
맥 잠그기 버튼
풀 수 있게 되니 잠그는 것도 아이폰에서 하고 싶어졌다. 특히 9편의 “잠자기 막기”를 켜 두고 나가면 맥이 잠기지 않는데, 깜빡했을 때 밖에서 잠글 방법이 있으면 좋다.
잠그는 건 푸는 것보다 훨씬 간단하다. macOS의 화면 잠금 단축키 ⌃⌘Q를 맥 앱이 대신 누르면 된다. 6편 키패드와 8편 특수 키 줄이 쓰는 “키를 누른 것처럼 보내기”를 그대로 쓴다.
1
2
3
4
5
6
7
8
9
10
enum MacLocker {
static let stroke = RemoteStep.key(CGKeyCode(kVK_ANSI_Q), [.maskControl, .maskCommand])
/// 권한이 없어 못 보냈으면 false
static func lock() -> Bool {
guard MediaKey.allowed else { return false }
RemoteTyper.postEvent(stroke)
return true
}
}
.maskControl과 .maskCommand는 Control과 Command를 같이 누른 상태를 뜻하는 CGEventFlags(6편의 flags)다. Q 키 번호에 이 둘을 실어 누르고 떼면 ⌃⌘Q가 된다.
푸는 것과 정한 것이 반대다.
| 잠금 풀기 | 맥 잠그기 | |
|---|---|---|
| 원격(Tailscale)에서 | 안 된다 | 된다 |
| 맥 메뉴의 기기별 허용 | 켜야 한다 | 없다 |
| Face ID | 거친다 | 안 거친다 |
잠그는 건 보안을 높이는 쪽이라 막을 까닭이 없다. 실수로 눌러도 다시 풀면 그만이라 확인 창도 두지 않았다. 버튼은 상태 줄 “밖에서 붙기” 옆의 자물쇠이고, 맥이 풀려 있을 때만 보인다. 잠기면 사라지고 대신 잠금 풀기 버튼이 뜬다.
맥은 잠기면 잠김 알림으로 lockState를 보낸다. 아이폰은 그걸로 잠겼는지 안다. 3초 안에 오지 않으면 맥에 손쉬운 사용 권한이 없거나 단축키가 먹지 않은 것으로 보고 “맥이 잠기지 않았어요. 맥의 손쉬운 사용 권한을 확인해 주세요”를 잠깐 띄운다. 실기기에서 눌러 보니 앱이 보낸 ⌃⌘Q로도 맥 화면이 바로 잠겼다. 밖에서 Tailscale로 붙은 채 잠그는 것은 아직 확인 못 했다.
이것도 메시지를 하나 더한 것이라 기능 번호를 6으로 올렸다. 맥 앱이 예전 버전이면 자물쇠 버튼이 보이지 않는다.
연결 상태 아이콘 정리
여기까지 오면서 상태 줄에 보이는 것이 꽤 늘었다. 점의 색, 길 아이콘, 자물쇠가 각각 무엇을 뜻하는지 한곳에 모았다.
길 아이콘은 맥이 실제로 들어온 연결을 보고 정해 보낸다(위 “와이파이를 꺼도 뜨던 잠금 풀기”). 같은 네트워크일 때만 기기가 자기 연결을 보고 와이파이인지 유선인지 고른다.
점의 색은 연결 상태다.
| 점 | 뜻 | 상태 줄 |
|---|---|---|
| 초록 | 연결됨 | 아이콘만(아이폰 세로) 또는 “맥에 연결됨” |
| 주황 + 자물쇠 | 연결됐지만 맥이 잠김. 버튼들이 흐려진다 | “맥이 잠겨 있어요. 잠금을 풀면 다시 쓸 수 있어요” |
| 주황 | 맥을 찾는 중이거나 연결하는 중, 허락을 기다리는 중 | “같은 와이파이에서 맥을 찾는 중” 등 |
| 빨강 | 직접 해 줘야 할 일이 있음 | 로컬 네트워크 권한 꺼짐, 집 와이파이에서 먼저 허락받기 |
상태 줄 오른쪽의 자물쇠와 지구본은 길이 아니라 버튼이다. 자물쇠는 맥 잠그기(맥이 풀려 있을 때만), 지구본은 “밖에서 붙기”(9편)다.
테스트
판단은 DeckKit에, 치는 순서는 맥 테스트에 두고 테스트부터 썼다. 키체인과 Face ID는 기기 기능이라 실기기에서 확인했다. DeckKit 199개, 맥 69개가 통과한다.
| 무엇 | 테스트 |
|---|---|
| 메시지 | unlockMessagesRoundTrip, unlockPasswordRoundTripsAnyCharacters |
| 기기별 허용 | oldTrustedDeviceHasUnlockOff, unlockAllowedFollowsSetter |
| 가까운 연결 | tailscaleIsRemote, localIsNearby, mappedTailscaleIsRemote, unknownHostIsNotNearby |
| 판단 순서 | checkOrder |
| 찍어 보기 막기 | threeFailuresBlockForFiveMinutes, failuresOutsideWindowDoNotCount, successClearsFailures |
| 치는 순서 | typesWholePasswordThenEnter, doesNothingWhenNotLocked, stopsWhenUnlockedMidway |
| 화면 보호기 | unlockNeedsSystemScreenLock |
| 로그 | unlockIsQuiet |
| 잠겨 있는 동안 | lockedMacRefusesActions, lockedMacStillAcceptsUnlockAndReads |
| 잠김 알림 | lockNotificationNameTellsTheState |
| 맥 잠그기 | lockMacMessageRoundTrips, lockMacNeedsProtocolSix, lockedMacIgnoresLockMac, lockStrokeIsControlCommandQ |
| 맥이 정하는 길 | macRouteUsesTailscaleAddressFirst, cableLinkIsWired, deviceRouteCombinesMacAndLocal, routeMessageRoundTrips |










