iPhone의 Apple Intelligence 에지 글로우(Edge Glow) 효과를 Mac에서 재현하기
요약
iPhone의 Apple Intelligence 에지 글로우 효과를 macOS 메뉴 바 앱으로 재현하는 과정을 다룹니다. Core Animation의 상태 손실 문제를 해결하기 위해 타이머 기반의 애니메이션 구현 방식을 제안합니다.
핵심 포인트
- Apple Intelligence의 에지 글로우 효과를 macOS용 오픈 소스 앱으로 구현
- CABasicAnimation 사용 시 창 가시성 변화에 따른 애니메이션 오류 발생 확인
- 상태 손실 문제를 해결하기 위해 60fps 타이머 기반 애니메이션 방식 채택
- CPU 사용량을 최소화하면서 견고한 애니메이션 효과 구현
iPhone의 Apple Intelligence는 Siri가 생각 중일 때 화면 가장자리에 아름다운 무지갯빛 글로우(iridescent glow)를 보여줍니다. 저는 Claude Code가 작동할 때 제 Mac에서도 동일한 효과를 보고 싶었습니다.
그래서 저는 EdgeGlow를 만들었습니다. 이는 iPhone의 에지 글로우(edge glow) 효과를 Mac에서 재현하는 무료 오픈 소스 macOS 메뉴 바 앱입니다.
이 포스트에서는 제가 이를 어떻게 구현했는지와 직면했던 기술적 과제들에 대해 설명하겠습니다.
작동 방식
AI Agent 트리거 훅(hook) → curl http://127.0.0.1:9876/start → 화면 글로우(glow) 발생
AI Agent 종료 → curl http://127.0.0.1:9876/stop → 글로우(glow) 사라짐
그게 전부입니다. 매우 간단합니다.
흥미로운 부분은 애니메이션입니다. 그 과정을 안내해 드리겠습니다.
기술적 과제
화면 가장자리를 따라 부드러운 마키(marquee) 효과, 즉 본질적으로 지속적으로 흐르는 점선(dashed line)이 필요했습니다.
왜 CABasicAnimation을 사용하지 않았나?
저의 첫 번째 접근 방식은 lineDashPhase에 CABasicAnimation을 사용하는 것이었습니다:
let anim = CABasicAnimation(keyPath: "lineDashPhase")
anim.fromValue = 0
anim.toValue = perimeter
...
이 방식은 작동했습니다... 창이 숨겨졌다가 다시 나타날 때까지는 말이죠.
Core Animation이 애니메이션 상태를 잃어버리게 되면, 마키(marquee) 효과는 다음과 같이 동작합니다:
- 그 자리에 멈춤 (Freeze)
- 갑자기 방향이 반전됨 (Reverse direction)
- 무작위 위치로 튀어 오름 (Jump)
근본 원인
CABasicAnimation은 레이어의 프레젠테이션 상태(presentation state)와 연결된 Core Animation의 애니메이션 시스템에 의존합니다. 창이 숨겨지거나(orderOut) 레이어가 계층 구조에서 제거되면 애니메이션 상태가 손실됩니다.
저는 몇 가지 해결책을 시도했습니다:
- 애니메이션 일시정지/재개 (Pause/resume animations) — 안정적으로 작동하지 않았습니다.
- 창을 계속 활성화 상태로 유지 (Keep the window alive) — 리소스 낭비였습니다.
- 표시될 때 애니메이션 초기화 (Reset animation on show) — 시각적 점프(visual jumps)를 유발했습니다.
그 어떤 것도 완벽하지 않았습니다.
해결책: 타이머 기반 애니메이션 (Timer-Driven Animation)
저는 lineDashPhase를 직접 업데이트하는 60fps의 Timer로 전환했습니다:
private var flowTimer: Timer?
private var dashPhase: CGFloat = 0
private var lastTickTime: CFTimeInterval = 0
...
이 방식은 **매우 견고(bulletproof)**합니다. Core Animation의 애니메이션 시스템에 의존하지 않으며, 창의 가시성(visibility) 변화에 따른 상태 손실도 없습니다.
성능 또한 매우 뛰어납니다. 60fps로 단일 CGFloat 속성을 업데이트하는 데 CPU 사용량은 약 0%에 불과합니다.
4개 레이어 + 20개 세그먼트 글로우(Glow) 효과
iPhone의 Apple Intelligence 에지 글로우(edge glow)를 재현하기 위해 두 가지가 필요했습니다.
1. 20개 세그먼트 그라데이션 컬러링 (20-segment gradient coloring): 화면 가장자리를 20개의 세그먼트로 나누고, 각 세그먼트에 서로 다른 색조(보라색 → 파란색 → 청록색 → 분홍색 → 주황색 → 금색)를 적용합니다. 세그먼트 경계에 가우시안 블러 (Gaussian blur)를 적용하면 부드러운 전환이 만들어집니다:
for i in 0..<20 {
let hue = (baseHue + Double(i) * 0.05).truncatingRemainder(dividingBy: 1.0)
// 각 세그먼트는 서로 다른 색을 가지며, 그라데이션을 형성합니다
...
2. 4개 레이어 글로우 스택 (4-layer glow stack): 서로 다른 블러(blur) 레벨을 가진 4개의 CAShapeLayer 인스턴스를 쌓아 사실적인 네온 글로우 효과를 만듭니다:
레이어 구성 (Layer Configuration)
let configs: [(widthMul: CGFloat, alphaMul: CGFloat, blur: Double)] = [
(baseWidth * 1.5, 0.15, 12.0), // 레이어 1: 넓은 선, 높은 블러, 낮은 알파 → 외부 글로우 (outer glow)
(baseWidth * 0.8, 0.30, 8.0), // 레이어 2: 중간 선, 중간 블러, 중간 알파 → 중간 글로우 (mid glow)
...
각 레이어 구축 (Building Each Layer)
for (lineWidth, alpha, blur) in configs {
let shape = CAShapeLayer()
shape.frame = ringLayer.bounds
...
결과 (The Result)
4개의 레이어가 서로 겹쳐 쌓이면서 사실적인 네온 조명 튜브 효과를 만들어냅니다:
레이어 4: ██ (밝은 중심부, 블러 없음)
레이어 3: ████ (코어 라인, 블러 2)
레이어 2: ██████ (중간 글로우, 블러 8)
...
외부 레이어는 부드러운 글로우 헤일로(glow halo)를 형성하고, 내부 레이어는 밝은 코어를 형성합니다. 이들이 합쳐져 실제 네온 조명처럼 보이게 됩니다.
멀티 터미널 참조 횟수 계산 (Multi-Terminal Reference Counting)
문제점 (The Problem)
여러 개의 Claude Code 터미널이 실행 중일 때, 하나에서 /stop을 호출하면 다른 터미널들이 여전히 활성 상태임에도 불구하고 글로우 효과가 종료되어 버립니다.
해결책: 참조 횟수 계산 (Reference Counting)
class ControlServer {
private var activeCount = 0
...
이제 /start는 카운트(count)를 증가시키고, /stop은 감소시킵니다. 글로우(glow) 효과는 카운트가 0에 도달했을 때만 숨겨집니다.
안전 타임아웃 (Safety Timeout)
만약 에이전트(agent)가 /stop을 보내지 않고 충돌(crash)한다면 어떻게 될까요? 참조 카운트(reference count)는 영원히 0보다 큰 상태로 유지될 것입니다.
해결책: 120초의 안전 타임아웃(safety timeout)을 설정합니다:
private func resetSafetyTimer() {
safetyTimer?.cancel()
let work = DispatchWorkItem { [weak self] in
...
모든 /start는 타이머를 재설정합니다. 60초 동안 /start가 수신되지 않으면 카운트는 0으로 재설정됩니다.
보안 고려 사항 (Security Considerations)
EdgeGlow는 127.0.0.1:9876에서 HTTP 서버를 실행합니다. 보안에 주의해야 했습니다:
로컬호스트 전용 (Localhost Only)
let param = NWParameters.tcp
param.acceptLocalOnly = true // localhost로부터의 연결만 허용
이는 외부 네트워크 접근을 방지합니다.
GET 요청만 허용 (GET Requests Only)
guard method == "GET" || method == "OPTIONS" else {
sendResponse(405, "Method Not Allowed", conn: conn)
return
...
CSRF 공격을 방지하기 위해 POST/PUT/DELETE 요청을 거부합니다.
CORS 헤더 없음 (No CORS Headers)
// Access-Control-Allow-Origin 헤더 없음
// 웹 JavaScript가 엔드포인트(endpoints)를 호출할 수 없음
이는 웹 페이지가 JavaScript를 통해 API를 호출하는 것을 방지합니다.
데이터 수집 없음 (No Data Collection)
- 분석(analytics) 없음
- 텔레메트리(telemetry) 없음
- 네트워크 요청 없음 (localhost 제외)
멀티 모니터 지원 (Multi-Monitor Support)
과제 (The Challenge)
크기가 서로 다른 여러 디스플레이를 어떻게 처리할 것인가?
해결책 (The Solution)
모든 화면 프레임(screen frames)의 합집합(union)을 계산합니다:
func totalScreenFrame() -> NSRect {
var frame = NSRect.zero
for screen in NSScreen.screens {
...
디스플레이 변경 처리 (Handling Display Changes)
화면 파라미터 변경을 감지합니다:
NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
...
디스플레이가 변경되면 새로운 프레임 크기에 맞춰 레이어(layers)를 다시 구축합니다.
설정 지속성 (Settings Persistence)
과제 (The Challenge)
설정을 반응형(reactive)으로 만들고 앱 실행 간에 지속(persist)되게 하려면 어떻게 해야 하는가?
해결책 (The Solution)
ObservableObject + UserDefaults + Combine을 사용합니다:
class AppSettings: ObservableObject {
@Published var speed: Int {
didSet {
...
그 다음, GlowWindow에서 변경 사항을 관찰(observe)합니다:
settings.$speed.sink { [weak self] _ in
self?.rebuildLayers()
}.store(in: &cancellables)
사용자가 속도 슬라이더(speed slider)를 변경하면, 레이어(layers)가 자동으로 재구성(rebuild)됩니다.
성능 (Performance)
| 지표 (Metric) | 값 (Value) | 비고 (Notes) |
|---|---|---|
| CPU | ~0% | Timer 60fps가 단 하나의 CGFloat만 업데이트함 |
| ... |
핵심 통찰(key insight): 60fps로 속성(property)을 업데이트하는 것은 비용이 적게 듭니다. 실제로 무거운 작업을 수행하는 것은 GPU 가속을 받는 CAShapeLayer 렌더링입니다.
체험하기 (Try It)
- GitHub: https://github.com/vector4wang/EdgeGlow
- Download: https://github.com/vector4wang/EdgeGlow/releases
- Size: 892KB (압축됨)
- License: MIT
- macOS: 13.0+ (Ventura)
저의 첫 번째 macOS 앱입니다. 외부 라이브러리(third-party dependencies) 없이 순수 Swift + SwiftUI로 제작되었습니다.
여러분의 피드백을 기다립니다!
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기