StreamDeck (14) 잠글 때 화면 끄기와 맥 재우기, 그리고 넘친 아이폰 상태 줄
이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.
11편에서 상태 줄의 자물쇠로 맥을 잠글 수 있게 했다. 그런데 잠그고 나서 보니 잠금 화면이 켜진 채로 계속 밝게 떠 있었다. 자리를 비우며 잠글 때는 화면도 꺼지면 좋겠고, 퇴근할 때처럼 맥을 아예 재우고 싶을 때도 있다. 그래서 둘 다 넣었다.
- 자물쇠로 잠그면 화면도 같이 끈다.
- 맥 재우기 버튼을 따로 둔다.
만들고 써 보는 동안 문제가 셋 더 나왔다. 재운 맥이 아이폰으로 풀리고, 깬 맥에서 잠금 풀기 버튼이 안 뜨고, 버튼이 늘어난 아이폰 상태 줄이 화면 밖으로 넘쳤다. 이 글은 그것까지 다룬다.
잠근 뒤에도 켜져 있던 화면
화면이 켜진 채 남은 까닭은 9편의 “잠자기 막기”다. 밖에서 화면을 보려고, 체크를 켜 두면 시스템 잠자기와 함께 디스플레이 꺼짐도 막게 했다. ⌃⌘Q로 잠그면 잠금 화면은 뜨지만, 화면을 끄는 건 macOS가 “가만히 있으면 끈다”로 하는 일이라 이 요청에 막힌다.
9편에서 정리한 것처럼 이 요청은 “가만히 있어서” 잠들거나 꺼지는 것만 막는다. 직접 끄라고 시키는 건 막지 않는다. 그래서 잠근 뒤에 화면을 직접 끄게 했다.
pmset이란?
pmset은 맥의 전원 관리를 다루는 맥 기본 명령이다. 원래는 “몇 분 뒤에 화면 끄기” 같은 전원 설정을 바꾸는 데 쓰는데, 설정을 바꾸려면 관리자 권한(root)이 필요하다. 다만 지금 바로 무언가를 시키는 명령도 있고, 이 둘은 권한 없이 된다.
| 명령 | 하는 일 |
|---|---|
pmset displaysleepnow | 화면만 지금 끈다. 맥은 깨어 있다 |
pmset sleepnow | 맥 전체를 지금 재운다 |
pmset -g log | 언제 잠들고 깼는지, 왜 깼는지 기록을 보여 준다 |
맥 앱은 3편의 runProcess로 pmset displaysleepnow를 실행한다. 셋째 명령은 아래에서 재우기가 이상하게 동작할 때 원인을 찾는 데 썼다.
아이폰이 잠근 때만 끄기
화면을 끄는 조건을 하나 걸었다. 아이폰 자물쇠로 잠그라고 한 뒤 3초 안에 맥이 실제로 잠겼을 때만 끈다.
- 손쉬운 사용 권한이 없으면 ⌃⌘Q가 안 먹어서 맥이 잠기지 않는다. 이때 화면만 꺼지면, 잠기지 않은 맥이 겉으로는 꺼져 보이는 채 남는다. 지나가던 누군가 마우스를 건드리면 그대로 쓸 수 있다.
- 내가 맥에서 직접 ⌃⌘Q를 누른 건 건드리지 않는다. 잠깐 잠그고 화면을 보려는 것일 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// UnlockPolicy.swift (DeckKit)
public struct LockDisplayOff: Sendable {
public static let window: TimeInterval = 3
private var requestedAt: Date?
public init() {}
public mutating func requested(at date: Date) {
requestedAt = date
}
/// 요청 뒤 0~3초 안의 잠김이면 true. 한 번 쓰면 요청을 지운다
public mutating func shouldTurnOff(lockedAt date: Date) -> Bool {
guard let requestedAt else { return false }
let elapsed = date.timeIntervalSince(requestedAt)
guard elapsed >= 0, elapsed <= Self.window else { return false }
self.requestedAt = nil
return true
}
}
timeIntervalSince로 요청 뒤 몇 초인지 재기
timeIntervalSince(_:)는 두 시각 사이가 몇 초인지 돌려준다. 잠긴 시각에서 요청 시각을 빼서 0초와 3초 사이면 끈다. 0보다 작으면 요청보다 먼저 잠긴 것이라 이번 요청과 상관없다. 한 번 쓴 요청은 지워서, 3초 안에 잠김이 두 번 와도 한 번만 끈다.
맥은 자물쇠 요청을 받아 ⌃⌘Q를 보내는 데 성공했을 때만 요청 시각을 적는다. 그리고 11편의 잠김 알림이 오면 이 판단을 물어 화면을 끈다.
1
2
3
4
5
6
7
// DeckServer.swift (Mac), 잠김 알림을 받는 곳
let locked = ScreenLock.locked(afterNotification: name)
for session in self.sessions { self.sendLockState(to: session, locked: locked) }
if locked == true, self.lockDisplayOff.shouldTurnOff(lockedAt: .now) {
print("[lock] display off")
Task.detached { _ = try? runProcess("/usr/bin/pmset", ["displaysleepnow"]) }
}
Task.detached로 메인 밖에서 pmset 돌리기
Task.detached는 지금 있는 자리(메인 스레드)와 상관없이 따로 도는 작업을 만든다. runProcess는 명령이 끝날 때까지 기다리는 함수라, 메인에서 부르면 그동안 맥 메뉴와 다른 기기의 요청 처리가 멈춘다. 그래서 따로 돌렸다. 결과는 쓰지 않아서 _ =로 버렸다.
꺼진 화면의 맥 풀기
화면이 꺼진 맥을 아이폰으로 풀 때가 걱정이었다. 꺼진 화면에 키를 치면 첫 키는 화면을 깨우는 데 쓰이고 사라질 수 있다. 그러면 비밀번호 첫 글자가 빠져 틀린 비밀번호가 되고, 11편의 “5분 안에 3번 틀리면 막기”에도 한 번이 세진다. 그래서 치기 전에 화면부터 깨운다.
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
// MacUnlocker.swift (Mac)
func type(_ password: String, done: @escaping @Sendable (Outcome) -> Void) {
let chunks = TextInput.chunks(password)
queue.async { [self] in
// 화면이 꺼진 채 치면 첫 글자가 화면을 깨우는 데 먹혀 틀린 비밀번호가 된다. 먼저 깨우고 켜질 때까지 기다린다
if isDisplayAsleep() {
wakeDisplay()
var tries = 0
repeat {
wait(0.1)
tries += 1
} while isDisplayAsleep() && tries < 20
guard !isDisplayAsleep() else { return done(.displayAsleep) }
// 비밀번호 칸이 뜰 틈
wait(0.5)
}
// 생략 (11편과 같이 묶음마다 잠금을 확인하며 친다)
}
}
// 실제 맥에서 쓰는 값
isDisplayAsleep: { CGDisplayIsAsleep(CGMainDisplayID()) != 0 },
wakeDisplay: {
var id = IOPMAssertionID(0)
if IOPMAssertionDeclareUserActivity("TouchDeck 잠금 풀기" as CFString, kIOPMUserActiveLocal, &id) == kIOReturnSuccess {
IOPMAssertionRelease(id)
}
},
CGDisplayIsAsleep과 IOPMAssertionDeclareUserActivity로 화면 깨우기
CGMainDisplayID()는 주 모니터의 번호를 돌려준다.CGDisplayIsAsleep(_:)는 그 모니터가 꺼져(잠들어) 있으면 0이 아닌 값을 돌려준다. 화면이 꺼져 있을 때만 깨운다.IOPMAssertionDeclareUserActivity는 “사람이 방금 맥을 만졌다”고 macOS에 알린다. 키보드를 한 번 누른 것과 같아서, 꺼진 화면이 켜진다. 키를 실제로 보내는 게 아니라 어느 앱에도 글자가 들어가지 않는다. 둘째 인자kIOPMUserActiveLocal은 맥 앞에 있는 사람이 만졌다는 뜻이다. 셋째 인자로 이 알림의 번호를 돌려받는다.IOPMAssertionRelease로 그 번호를 바로 놓는다(9편에서 잠자기 막기를 풀 때 쓴 것과 같다). 놓지 않으면 “사람이 쓰는 중”이 계속 남는다.- 0.1초마다 다시 보고, 2초(20번)가 지나도 안 켜지면 치지 않는다. 켜졌으면 잠금 화면에 비밀번호 칸이 뜰 0.5초를 더 기다린다.
처음에는 3번을 빠뜨렸다. 나중에 전원 상태를 보니 TouchDeck이 “TouchDeck 잠금 풀기”를 4개나 쥐고 있었다. 잠금을 풀 때마다 하나씩 쌓인 것이다.
결과는 셋으로 나눴다. 다 쳤으면 .typed, 잠겨 있지 않아 멈췄으면 .notLocked, 화면이 안 켜져서 안 쳤으면 .displayAsleep이다. 마지막은 아이폰에 아무 결과도 보내지 않고 틀린 횟수에도 넣지 않는다. 새 결과 종류를 만들어 보내면 예전 아이폰 앱이 그 메시지를 읽지 못한다. 대신 아이폰은 11편의 “8초 안에 답이 없으면 다시 연결” 흐름을 탄다.
전원 기록으로 확인하니 의도대로 됐다. 화면이 꺼지고(Display is turned off), 몇 초 뒤 TouchDeck이 화면을 깨우고(TouchDeck 잠금 풀기), 화면이 켜진 뒤(Display is turned on) 풀렸다.
맥 재우기 버튼
상태 줄에 달 모양 버튼을 더했다. 누르면 확인 창을 거쳐 맥 전체를 재운다.
잠그기와 달리 재우기는 되돌리기 어렵다. 잠든 맥은 TouchDeck도 멈춰서, 아이폰이 무엇을 보내도 받을 쪽이 없다. 그래서 조건을 붙였다.
- 누르면 “맥을 잠재울까요?” 확인 창이 뜬다.
- 밖(Tailscale)에서는 버튼이 안 보이고, 맥도 거절한다. 밖에서 실수로 누르면 집에 갈 때까지 맥을 못 쓴다.
- 맥이 잠겨 있어도 재울 수 있다. 재우는 건 보안을 낮추지 않는다.
맥은 붙어 있는 기기들에 “곧 잠든다”(macSleeping)를 먼저 보내고, 0.5초 뒤 pmset sleepnow를 실행한다. 알림이 나갈 틈이다. 메시지가 하나 늘어서 기능 번호를 8로 올렸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// DeckServer.swift (Mac), sleepMac 안
let (status, said) = await Task.detached { () -> (Int32?, String) in
// 실패하면 pmset이 오류 글을 남긴다. 원인을 알 수 있게 출력과 오류를 같이 받는다
let pipe = Pipe()
let process = Process()
process.executableURL = URL(filePath: "/usr/bin/pmset")
process.arguments = ["sleepnow"]
process.standardOutput = pipe
process.standardError = pipe
let status: Int32? = (try? process.run()).map { process.waitUntilExit(); return process.terminationStatus }
let said = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
return (status, said.trimmingCharacters(in: .whitespacesAndNewlines))
}.value
print("[sleep] pmset sleepnow:", status.map(String.init) ?? "nil", said)
guard status != 0 else { return }
session?.send(.sleepFailed)
Process와 Pipe로 명령의 오류 글까지 받기
Pipe는 한쪽에서 쓴 글을 다른 쪽에서 읽는 통로다. 실행한 명령이 화면에 찍을 글을 여기로 받는다.Process로pmset을 실행한다. 3편의runProcess와 같은 도구인데, 거기서는 오류 글을 버렸다. 여기서는standardOutput(보통 출력)과standardError(오류 출력)을 같은 통로에 이어서 둘 다 받는다.waitUntilExit()로 끝날 때까지 기다리고,terminationStatus로 종료 코드(3편)를 읽는다. 0이 아니면 실패다.fileHandleForReading의readDataToEndOfFile()로 통로에 쌓인 글을 모두 읽어 로그에 남긴다.
처음에는 종료 코드만 봤다. 실패하면 이유를 알 수 없어서 오류 글까지 받게 고쳤다.
아이폰의 확인 창은 SwiftUI의 확인 창을 썼다.
1
2
3
4
5
6
7
8
9
// DeckView.swift (iOS)
.confirmationDialog("맥을 잠재울까요?", isPresented: $confirmSleep, titleVisibility: .visible) {
Button("잠재우기", role: .destructive) { client.sleepMac() }
Button("취소", role: .cancel) {}
} message: {
Text(client.shownRoute == .wired
? "케이블로 연결돼 있으면 잠든 뒤에도 아이폰에서 잠금 풀기를 눌러 맥을 깨울 수 있어요. 맥 키보드나 트랙패드로 깨워도 돼요."
: "잠들면 아이폰으로 깨울 수 없어요. 맥 키보드나 트랙패드를 눌러 깨우면 다시 붙어요.")
}
confirmationDialog로 한 번 더 묻기
confirmationDialog(_:isPresented:titleVisibility:actions:message:)는 아이폰에서는 화면 아래에서 올라오는 선택지, 아이패드에서는 작은 말풍선으로 뜨는 확인 창이다. isPresented가 true가 되면 뜬다. titleVisibility: .visible로 제목을 늘 보이게 했다. role: .destructive를 준 버튼은 빨간 글씨로 보여서, 되돌리기 어려운 동작이라는 걸 알린다. .cancel은 취소 자리에 놓인다. 케이블일 때의 안내 글은 아래 “재운 맥이 풀리던 까닭”에서 다룬다.
재운 맥이 풀리던 까닭
실기기에서 재우기를 눌러 보니 이상했다. 재웠는데도 아이폰으로 잠금이 풀렸다. 재운 맥은 TouchDeck이 멈춰서 풀릴 수 없어야 한다. 또 어떤 때는 누르자마자 “맥이 잠들지 않았어요”가 떴다.
전원 기록을 봤다. pmset -g log는 맥이 언제 잠들고 언제 왜 깼는지 남긴다.
1
2
3
4
5
6
05:43:27 Sleep Entering Sleep state due to 'Software Sleep pid=57291'
05:43:29 DarkWake DarkWake from Deep Idle : due to ATC2.CIOWakeup/HID Activity
05:43:29 Wake DarkWake to FullWake from Deep Idle : due to HID Activity
// 생략
05:47:20 Sleep Entering Sleep state due to 'Software Sleep pid=58345'
05:48:03 Wake Wake from Deep Idle : due to trackpadkeyboard
05:43:27에 맥은 실제로 잠들었다. 그런데 2초 뒤 깨어났고, 깬 이유가 ATC2.CIOWakeup이었다. USB-C 포트 쪽에서 온 신호로 깼다는 뜻이다. 그 순간 TouchDeck이 잠금 풀기를 위해 화면을 깨운 기록도 같이 있었다. 그때 아이폰은 케이블로 맥에 꽂혀 있었다.
케이블로 깨어난 맥
아이폰을 케이블로 맥에 꽂으면, 맥은 그 케이블을 네트워크 장치 하나로 본다(11편의 유선 연결). 맥은 잠들어도 이 장치로 데이터가 오면 깨어날 수 있다. 재운 직후 아이폰에서 잠금 풀기를 눌렀고, 그 요청이 케이블을 타고 가서 맥을 깨웠다. 깬 맥은 요청을 받아 그대로 풀었다.
와이파이는 이렇게 깨우지 못한다. macOS에는 “네트워크 접근 시 깨우기” 설정이 있지만 이 맥은 꺼져 있다(pmset -g의 womp 0). 그래서 05:47:20 재우기는 와이파이 상태라 키보드로 깨울 때까지 잠든 채 있었다.
케이블로 꽂아 두기만 해도 맥이 계속 깨는지도 봤다. 꽂은 채 재우고 아이폰은 만지지 않았더니, 05:51:05에 잠들어 1분 32초 뒤 키보드로 깨울 때까지 잠든 채였다. 가만히 있으면 잠들어 있고, 아이폰이 무언가를 보내면 깬다.
그래서 케이블일 때는 재우기 확인 창 글을 “잠든 뒤에도 아이폰에서 잠금 풀기를 눌러 맥을 깨울 수 있어요”로 바꿨다. 아이폰으로 맥을 깨울 수 있는 길이 우연히 생긴 셈이다.
그 길을 살려 “맥 깨우기”도 따로 넣었다. 케이블로 붙은 채 재웠을 때만 ⋯ 메뉴에 보이고, 누르면 비밀번호는 치지 않고 화면만 켠다. 맥에서 직접 비밀번호를 치거나 그 기기에 잠금 풀기를 허용하지 않았을 때 쓴다. 케이블 신호로 깬 맥은 처음에 화면이 꺼진 채 잠깐만 깨어 있는 상태(DarkWake)라, 맥 앱이 이 요청을 받으면 위의 IOPMAssertionDeclareUserActivity로 “사람이 만졌다”를 알려 완전히 깨운다. 메시지가 하나 늘어서 기능 번호는 9가 됐다.
아이패드를 케이블로 맥에 꽂은 채 재운 화면이다. 상태 줄에 “맥이 잠들었어요. 맥 키보드를 누르거나 잠금 풀기를 눌러 깨워요”와 “유선” 이름표가 있고, 오른쪽에 “맥 깨우기”와 “다시 연결”이 있다. 맥이 잠들며 잠겨서 아래에는 “맥 잠금 풀기” 버튼도 떠 있다. 이 상태에서 “맥 깨우기”를 누르면 맥 화면이 켜졌다.
“누르자마자 잠들지 않았어요”는 어느 시도에서 났는지 기록만으로는 가려낼 수 없었다. 위에서 오류 글까지 받게 고친 건 그래서다. 한때는 쌓여 있던 “TouchDeck 잠금 풀기” 4개가 재우기를 막은 게 아닐까 했다. 하지만 05:43:27에 그 4개를 쥔 채로도 잠들었으니 원인은 아니었다.
깬 맥에서 안 뜨던 잠금 풀기 버튼
맥을 키보드로 깨우면 잠금 화면이 뜬다. 그런데 아이폰에 잠금 풀기 버튼이 안 떴고, 앱을 껐다 켜야 떴다.
전원 기록의 잠든 줄 끝에 TCPKeepAlive=active가 붙어 있었다. 이게 실마리였다.
자는 동안 연결을 살려 두는 TCPKeepAlive란?
5편에서 끊긴 연결을 알아채려고 keepalive를 켰다. 몇 초마다 “살아 있어요?”를 묻고, 답이 없으면 끊긴 것으로 본다. 그런데 맥은 자는 동안에도 네트워크 칩이 이 물음에 대신 답해 준다. 잠깐 자는 동안 연결이 끊기지 않게 하려는 macOS 기능이다.
그래서 아이폰은 맥이 잠든 걸 끊김으로 알아채지 못한다. 낡은 연결을 붙든 채로 있고, 맥이 깨도 새로 붙지 않는다. 버튼이 안 뜬 건 그 연결로 잠금 소식이 제대로 오지 않아서로 보인다. 앱을 껐다 켜면 연결을 새로 맺으니 그때는 떴다.
스스로 끊고, 깨면 다시 알리기
세 가지를 고쳤다.
1
2
3
4
5
6
7
// DeckClient.swift (iOS), 맥이 보낸 메시지를 받는 곳
case .macSleeping:
macAsleep = true
asleepOverCable = shownRoute == .wired
if !asleepOverCable { reconnectLater(after: .seconds(6)) }
case .macAwake:
macAsleep = false
- 아이폰은 “곧 잠든다”를 받으면 스스로 연결을 끊고 6초 뒤에 다시 찾는다. 6초를 둔 건 맥이 다 잠들기 전에 다시 붙지 않게 하려는 것이다. 맥이 깨면 새 연결로 붙으면서 잠금 상태를 새로 받는다. 케이블은 끊지 않는다. 연결을 둬야 잠금 풀기 신호로 맥을 깨울 수 있어서다.
- 맥은 잠에서 깨면 붙어 있는 기기들에 “깼어요”(
macAwake)와 잠금 상태, 연결 길을 다시 보낸다. 연결이 살아남은 기기(케이블)를 위한 것이다. - 상태 줄에 “다시 연결” 버튼을 넣었다. 누르면 앱을 껐다 켠 것처럼 연결을 끊고 처음부터 찾는다. 어떤 까닭으로든 낡은 연결에 갇혔을 때 앱을 다시 켜지 않아도 된다.
1
2
3
4
5
6
7
8
9
10
11
12
// DeckServer.swift (Mac)
lockObservers.append(NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
for session in self.sessions where session.stage == .authorized {
if session.protocolVersion >= DeckProtocol.sleepMac { session.send(.macAwake) }
self.sendLockState(to: session)
self.sendRoute(to: session)
}
}
})
NSWorkspace.didWakeNotification으로 깬 때 알기
NSWorkspace.shared.notificationCenter는 맥 전체에 일어난 일(앱이 켜짐, 잠듦, 깸 등)을 알려 주는 알림 창구다. 11편의 잠김 알림과는 다른 창구다. 여기에 didWakeNotification을 들으면 맥이 잠에서 깬 순간을 받는다. queue: .main으로 받아서 메인 스레드에서 기기 목록을 다룬다. “깼어요”는 이 메시지를 아는 기능 번호 8 이상의 기기에만 보낸다.
처음에는 “재우라고 했는데 20초가 지나도 연결이 살아 있으면 안 잠든 것”으로 보고 “맥이 잠들지 않았어요”를 띄웠다. TCPKeepAlive 때문에 잘 잠들어도 연결이 살아 보이니 틀린 판단이라 뺐다.
고친 뒤 실기기에서 보니, 깬 맥에서 잠금 풀기 버튼이 앱을 다시 켜지 않아도 저절로 떴다. 잠든 맥을 아이폰으로 깨우는 건 케이블로 붙어 있을 때만 됐다.
넘친 아이폰 상태 줄
달 버튼과 다시 연결 버튼을 더하고 아이폰에서 보니 화면이 깨져 있었다.
아이폰 세로 화면이다. 버튼판 오른쪽 열과 위의 “맥 화면”이 잘려 있고, 화면 전체가 오른쪽으로 밀려 있다.
윗줄 한 줄에 점, 와이파이 아이콘, 자물쇠, 달, 다시 연결, 지구본, 그리고 화면 고르기 세 칸이 다 들어 있었다. 이 줄이 화면 폭보다 넓어지자, 그 줄을 담은 화면 전체가 넓어지면서 밀려났다. 11편에서 좁은 화면은 아이콘만 보이게 했는데, 버튼이 두 개 늘자 그것으로도 모자랐다.
아이폰 가로 화면의 윗줄이다. 아이폰 가로는 아이패드처럼 글로 보여 주는데, 밖에서 붙지 못했다는 안내가 한 줄에 다 안 들어가 뒤가 “켜…”로 잘렸다. 아이콘만 남은 버튼들도 손가락에 비해 작아서 누르기 어려웠다.
버튼을 메뉴 하나로
아이폰은 버튼 넷(맥 잠그기, 맥 재우기, 다시 연결, 밖에서 붙기)을 ⋯ 버튼 하나에 담았다. 세로에서는 화면 고르기를 둘째 줄로 내렸다. 아이패드는 자리가 넉넉해서 지금처럼 펼쳐 둔다.
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
// ConnectionStatusView.swift (iOS)
private var actionsMenu: some View {
Menu {
if let onLock {
Button(action: onLock) { Label("맥 잠그기", systemImage: "lock") }
}
if let onSleep {
Button(action: onSleep) { Label("맥 재우기", systemImage: "moon.zzz") }
}
if let onRefresh {
Button(action: onRefresh) { Label("다시 연결", systemImage: "arrow.clockwise") }
}
if let onRemote {
Button(action: onRemote) { Label("밖에서 붙기", systemImage: "globe") }
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.title2)
.frame(width: 44, height: 44)
.contentShape(.rect)
}
}
// DeckView.swift (iOS)
private static var isPhone: Bool { UIDevice.current.userInterfaceIdiom == .phone }
Menu와 userInterfaceIdiom으로 아이폰만 메뉴에 담기
Menu는 누르면 항목 목록이 펼쳐지는 버튼이다. 첫 묶음이 펼쳐질 항목,label:이 평소 보이는 버튼 모양이다. 펼쳐진 항목은 아이폰 기본 메뉴라 한 줄 한 줄이 손가락 크기로 크다.- 항목은
Label로 아이콘과 글을 같이 보여 준다. 지금 할 수 없는 동작은 넘어오는 값이 nil이라 항목이 빠진다. 맥이 잠겨 있으면 “맥 잠그기”가 없고, 밖에서는 “맥 재우기”가 없다. - ⋯ 버튼은
frame(width: 44, height: 44)로 키웠다. 애플이 손가락으로 누를 버튼에 권하는 최소 크기가 44pt다.contentShape(.rect)로 아이콘 둘레의 빈 곳을 눌러도 눌리게 했다. userInterfaceIdiom은 지금 기기가 아이폰(.phone)인지 아이패드(.pad)인지 알려 준다. 화면 폭 대신 기기로 나눈 건, 아이폰은 가로로 돌려도 이 줄에 버튼을 펼칠 만큼 넉넉하지 않아서다.
세로에서는 윗줄에 점, 길 아이콘, ⋯만 남고, 화면 고르기는 둘째 줄에 화면 폭 가득 들어간다. 옮기기 모드(13편)의 “완료”도 그 자리에 뜬다. 가로에서는 높이가 귀해서 한 줄을 지키되, 안내 글을 두 줄까지 쓰고 그래도 길면 글자를 조금 줄인다.
고친 아이폰 세로 화면이다. 오른쪽 위에 ⋯ 버튼이 있고, 화면 고르기가 둘째 줄에 화면 폭 가득 있다. 버튼판도 더는 밀리지 않는다.
⋯를 누른 화면이다. 맥 잠그기, 맥 재우기, 다시 연결, 밖에서 붙기가 큰 줄로 펼쳐진다.
같이 고친 게 하나 있다. 밖에서 붙지 못했을 때 안내가 “아이패드와 맥에서 Tailscale이 켜져 있는지…”였다. 아이폰에서도 아이패드라고 해서, “이 기기와 맥에서”로 바꿨다.
노치 양옆으로 올린 윗줄
아이폰 세로 화면 맨 위는 다이내믹 아일랜드 양옆이 비어 있다. 시계와 배터리가 보이는 시스템 상태 막대를 이 앱에서는 숨겨 둬서다. 그래서 세로에서는 점과 길 아이콘을 노치 왼쪽에, ⋯ 버튼을 오른쪽에 올리고, 화면 고르기와 버튼판을 그만큼 위로 당겼다. 가로에서는 아이패드처럼 점, 길 아이콘, ⋯, 화면 고르기를 윗줄 한 줄에 다 넣는다.
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
// DeckView.swift (iOS)
GeometryReader { geo in
let safeTop = geo.safeAreaInsets.top
let ears = Self.isPhone && safeTop > 30
VStack(spacing: 14) {
if ears {
VStack(spacing: 12) {
statusBlock(ears: true)
screenSwitch(fill: true)
}
} else {
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
statusBlock(ears: false)
screenSwitch(fill: false)
}
VStack(spacing: 12) {
statusBlock(ears: false)
screenSwitch(fill: true)
}
}
}
// 생략
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
.padding(.top, ears ? max(6, (safeTop - 44) / 2) - safeTop : 20)
}
safeAreaInsets와 ViewThatFits로 윗줄 모양 고르기
geo.safeAreaInsets.top은GeometryReader(4편)가 알려 주는 위쪽 안전 영역(8편)의 높이다. 아이폰은 노치가 위에 있는 세로일 때만 이 값이 크고, 가로로 돌리면 노치가 옆으로 가서 0이 된다. 그래서 가로인지 따로 묻지 않고, 이 값이 30pt보다 크면 노치 양옆 배치(ears)로 그린다.- 노치 양옆 배치는 둘째 줄에 화면 고르기를 화면 폭 가득(
fill: true) 놓는다. 노치 옆 빈 곳은 좁아서 화면 고르기까지 넣을 수 없다. - 노치 양옆 배치가 아니면
ViewThatFits가 고른다. 안에 넣은 후보를 위에서부터 차례로 맞춰 보고, 주어진 폭에 다 들어가는 첫 후보를 그린다.in: .horizontal은 가로 폭만 따진다는 뜻이다. 한 줄(HStack)이 들어가면 한 줄로, 넘치면 두 줄(VStack)로 그린다. 아이폰 가로와 아이패드 가로는 한 줄, 아이패드 세로는 두 줄이 된다. padding(.top, ...)은 위쪽 여백이다. SwiftUI는 기본으로 안전 영역 아래부터 그리니, 노치 양옆 배치에서는 안전 영역 높이(safeTop)만큼 빼서 노치 높이 안으로 끌어올린다.(safeTop - 44) / 2는 높이 44pt인 윗줄이 노치 높이의 가운데에 오게 하는 값이고,max(6, ...)는 화면 맨 위에 붙지 않게 최소 6pt를 남긴다.
돌리면 노치 배치로 남던 윗줄
처음에는 위쪽 안전 영역을 GeometryReader에서만 읽지 않고, 아이폰 창(UIKit의 window)에서도 읽어 둘 중 큰 값을 썼다. 그때는 GeometryReader가 안전 영역을 무시하고 화면 맨 위부터 그리게 해 뒀는데, 그러면 geo의 안전 영역 값이 0으로 올 수 있어서였다.
1
2
3
4
5
6
7
// DeckView.swift (iOS), 고치기 전
let safeTop = max(geo.safeAreaInsets.top, Self.windowSafeTop)
private static var windowSafeTop: CGFloat {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
return scenes.flatMap(\.windows).first(where: \.isKeyWindow)?.safeAreaInsets.top ?? 0
}
그런데 실기기에서 세로로 켠 뒤 가로로 돌리면, 윗줄이 한 줄이 되지 않고 화면 고르기가 둘째 줄에 내려온 채로 남았다. 버튼판은 가로 모양으로 잘 바뀌는데 윗줄만 세로 모양이었다. 안전 영역 값을 화면에 글로 띄워 보면, 그때는 값도 맞고 윗줄도 한 줄로 바르게 그려졌다. 글을 지우자 다시 두 줄로 돌아갔다.
SwiftUI가 화면을 다시 그리는 때
SwiftUI는 화면을 아무 때나 다시 그리지 않는다. 화면을 그리는 데 쓴 값 가운데 SwiftUI가 지켜보는 값이 바뀔 때만 다시 그린다. 지켜보는 값은 @State 같은 상태, verticalSizeClass 같은 환경 값, 그리고 GeometryReader가 알려 주는 크기와 안전 영역 같은 것이다. 택배에 비유하면, 도착 알림을 신청한 택배는 오면 알려 주지만, 내가 직접 가서 열어 본 우편함은 나중에 새 우편이 들어와도 아무도 알려 주지 않는 것과 같다.
창의 safeAreaInsets는 UIKit 값이라 SwiftUI가 지켜보지 않는다. 직접 가서 열어 본 우편함 쪽이다.
- 아이폰을 돌리면
GeometryReader의 크기가 바뀌어 SwiftUI가 윗줄을 다시 그린다. - 그 순간 창의 안전 영역은 아직 세로일 때 값(노치 높이)이었다. 둘 중 큰 값을 쓰니 세로 값이 이겨
ears가 참이 되고, 가로인데도 노치 양옆 배치로 그렸다. - 잠시 뒤 창의 값도 0으로 바뀌지만, SwiftUI는 그 값을 지켜보지 않으니 다시 그리지 않는다. 윗줄은 노치 배치 그대로 남는다.
- 값을 띄운 글은 화면에 쓰는 값이라, 그 글 때문에 화면이 한 번 더 그려졌다. 그때는 창의 값도 0이 된 뒤라 바르게 그려져서, 글이 있을 때만 멀쩡해 보였다.
고칠 때는 창에서 읽는 부분을 빼고 geo.safeAreaInsets.top만 쓰게 했다. GeometryReader가 안전 영역을 무시하던 것도 빼서, 이 값이 늘 제대로 오게 했다. 대신 노치 양옆 배치는 위의 4번처럼 여백에서 안전 영역 높이를 빼서 끌어올린다.
1
2
// DeckView.swift (iOS), 고친 뒤
let safeTop = geo.safeAreaInsets.top
고친 뒤 실기기에서 세로와 가로를 여러 번 돌려 봤다. 가로에서는 윗줄이 한 줄, 세로에서는 노치 양옆과 둘째 줄 화면 고르기로 매번 바르게 바뀌었다.
테스트
판단은 DeckKit에, 화면 깨우기 순서는 맥 테스트에 두고 테스트부터 썼다. DeckKit 238개, 맥 74개가 통과한다. 화면 끄기, 재우기, 케이블로 깨어나기는 전원 기록으로 실기기에서 확인했다.
| 무엇 | 테스트 |
|---|---|
| 메시지 | sleepMessagesRoundTrip, macAwakeRoundTrips |
| 기능 번호 | sleepMacNeedsProtocolEight |
| 잠긴 동안 | lockedMacStillAcceptsSleep |
| 화면 끄기 판단 | turnsOffWhenLockFollowsRequest, ignoresLockWithoutRequest, ignoresLateLock, ignoresLockBeforeRequest, requestIsUsedOnce |
| 화면 깨우고 치기 | wakesDisplayBeforeTyping, awakeDisplayTypesWithoutWaking, displayThatStaysAsleepIsNotTyped |
#expect 안에서는 값을 바꾸는 함수(mutating)를 부를 수 없어서, 화면 끄기 판단 테스트는 결과를 먼저 받아 둔 뒤 확인했다.
1
2
3
4
5
var d = LockDisplayOff()
let t = Date(timeIntervalSinceReferenceDate: 1000)
d.requested(at: t)
let off = d.shouldTurnOff(lockedAt: t.addingTimeInterval(0.4))
#expect(off)







