iPhone's Apple Intelligence shows a beautiful iridescent glow around the screen edges when Siri is thinking. I wanted that same effect on my Mac when Claude Code is working.
So I built EdgeGlow β a free, open-source macOS menu bar app that recreates iPhone's edge glow effect on Mac.
In this post, I'll walk through how I did it and the technical challenges I faced.
AI Agent triggers hook β curl http://127.0.0.1:9876/start β Screen glows
AI Agent finishes β curl http://127.0.0.1:9876/stop β Glow fades out
That's it. Dead simple.
The interesting part is the animation. Let me walk you through it.
I needed a smooth marquee effect around the screen edges β essentially a dashed line that flows continuously.
My first approach used CABasicAnimation
on lineDashPhase
:
let anim = CABasicAnimation(keyPath: "lineDashPhase")
anim.fromValue = 0
anim.toValue = perimeter
anim.duration = 5.0
anim.repeatCount = .infinity
shape.add(anim, forKey: "flow")
This worked... until the window was hidden and shown again.
Core Animation would lose the animation state, and the marquee would:
CABasicAnimation
relies on Core Animation's animation system, which is tied to the layer's presentation state. When the window is hidden (orderOut
) or the layer is removed from the hierarchy, the animation state is lost.
I tried several workarounds:
None of them were bulletproof.
I switched to a Timer
at 60fps that directly updates lineDashPhase
:
private var flowTimer: Timer?
private var dashPhase: CGFloat = 0
private var lastTickTime: CFTimeInterval = 0
private func startFlow() {
stopFlow()
lastTickTime = CACurrentMediaTime()
flowTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
self?.tickFlow()
}
RunLoop.main.add(flowTimer!, forMode: .common)
}
private func tickFlow() {
let now = CACurrentMediaTime()
let dt = CGFloat(now - lastTickTime)
lastTickTime = now
// Clamp dt to prevent huge jumps (e.g., returning from background)
let clampedDt = min(dt, 0.1)
let perimeter = currentPerimeter()
guard perimeter > 0, settings.animationDuration > 0 else { return }
let speed = perimeter / CGFloat(settings.animationDuration)
dashPhase += speed * clampedDt * (settings.clockwise ? 1 : -1)
ringLayer.sublayers?.forEach { layer in
(layer as? CAShapeLayer)?.lineDashPhase = dashPhase
}
}
This is bulletproof β no dependency on Core Animation's animation system, no state loss on window visibility changes.
The performance is also great: updating a single CGFloat
property at 60fps uses ~0% CPU.
To recreate iPhone's Apple Intelligence edge glow, I needed two things:
1. 20-segment gradient coloring: Split the screen edge into 20 segments, each with a different hue (purple β blue β cyan β pink β orange β gold). Gaussian blur at segment boundaries creates smooth transitions:
for i in 0..<20 {
let hue = (baseHue + Double(i) * 0.05).truncatingRemainder(dividingBy: 1.0)
// Each segment has a different color, forming a gradient
}
2. 4-layer glow stack: Stack 4 CAShapeLayer
instances with different blur levels to create a realistic neon glow effect:
let configs: [(widthMul: CGFloat, alphaMul: CGFloat, blur: Double)] = [
(baseWidth * 1.5, 0.15, 12.0), // Layer 1: Wide line, high blur, low alpha β outer glow
(baseWidth * 0.8, 0.30, 8.0), // Layer 2: Medium line, medium blur, medium alpha β mid glow
(baseWidth * 0.3, 0.70, 2.0), // Layer 3: Thin line, low blur, high alpha β core line
(baseWidth * 0.1, 0.95, 0.0), // Layer 4: Thinnest line, no blur, full alpha β bright center
]
js
for (lineWidth, alpha, blur) in configs {
let shape = CAShapeLayer()
shape.frame = ringLayer.bounds
shape.path = path
shape.fillColor = nil
shape.strokeColor = color.withAlphaComponent(alpha).cgColor
shape.lineWidth = lineWidth
shape.lineCap = .round
shape.lineDashPattern = [NSNumber(value: Double(dashLen)),
NSNumber(value: Double(gapLen))]
if blur > 0 {
let bf = CIFilter(name: "CIGaussianBlur")!
bf.setValue(blur, forKey: "inputRadius")
shape.filters = [bf]
}
ringLayer.addSublayer(shape)
}
The 4 layers stack on top of each other, creating a realistic neon light tube effect:
Layer 4: ββ (bright center, no blur)
Layer 3: ββββ (core line, blur 2)
Layer 2: ββββββ (mid glow, blur 8)
Layer 1: ββββββββ (outer glow, blur 12)
The outer layers create the soft glow halo, while the inner layers create the bright core. Together, they look like a real neon light.
If you have multiple Claude Code terminals running, one calling /stop
would kill the glow even though others are still active.
class ControlServer {
private var activeCount = 0
private func processRequest(_ raw: String, conn: NWConnection) {
switch path {
case "/start":
activeCount += 1
onStart?()
resetSafetyTimer()
case "/stop":
activeCount = max(0, activeCount - 1)
if activeCount == 0 { onStop?() }
case "/pulse":
activeCount = max(0, activeCount - 1)
if activeCount == 0 { onPulse?() }
}
}
}
Now /start
increments the count, /stop
decrements it. The glow only hides when the count reaches 0.
What if an agent crashes without sending /stop
? The reference count would stay > 0 forever.
Solution: a 120-second safety timeout:
private func resetSafetyTimer() {
safetyTimer?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
if self.activeCount > 0 {
print("β° 60s no activity, resetting to 0")
self.activeCount = 0
self.onStop?()
}
}
safetyTimer = work
DispatchQueue.main.asyncAfter(deadline: .now() + 60.0, execute: work)
}
Every /start
resets the timer. If no /start
is received for 60 seconds, the count resets to 0.
EdgeGlow runs an HTTP server on 127.0.0.1:9876
. I had to be careful about security:
let param = NWParameters.tcp
param.acceptLocalOnly = true // Only accept connections from localhost
This prevents external network access.
guard method == "GET" || method == "OPTIONS" else {
sendResponse(405, "Method Not Allowed", conn: conn)
return
}
Rejects POST/PUT/DELETE to prevent CSRF attacks.
// No Access-Control-Allow-Origin header
// Web JavaScript cannot invoke endpoints
This prevents web pages from calling the API via JavaScript.
How to handle multiple displays with different sizes?
Calculate the union of all screen frames:
func totalScreenFrame() -> NSRect {
var frame = NSRect.zero
for screen in NSScreen.screens {
frame = NSUnionRect(frame, screen.frame)
}
return frame
}
Listen to screen parameter changes:
NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
// Debounce 500ms to avoid rapid rebuilds
self?.screenChangeWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.handleScreenChange()
}
self?.screenChangeWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
}
When displays change, rebuild the layers with the new frame size.
How to make settings reactive and persist them across launches?
Use ObservableObject
-
UserDefaults -
Combine:
class AppSettings: ObservableObject {
@Published var speed: Int {
didSet {
UserDefaults.standard.set(speed, forKey: "speed")
notifyChange()
}
}
static let shared = AppSettings()
private init() {
self.speed = UserDefaults.standard.integer(forKey: "speed")
if speed == 0 { speed = 5 } // default
}
}
Then in GlowWindow, observe changes:
settings.$speed.sink { [weak self] _ in
self?.rebuildLayers()
}.store(in: &cancellables)
When the user changes the speed slider, the layers rebuild automatically.
| Metric | Value | Notes |
|---|---|---|
| CPU | ~0% | Timer 60fps only updates one CGFloat |
| Memory | ~50MB | 4 CAShapeLayer + CIFilter |
| Disk | 892KB | Universal Binary (arm64 + x86_64) |
| Network | 0 | localhost only, no external requests |
The key insight: updating a property at 60fps is cheap. It's the GPU-accelerated CAShapeLayer rendering that does the heavy lifting.
It's my first macOS app. Built with pure Swift + SwiftUI, no third-party dependencies.
Would love to hear your feedback!