# I Recreated iPhone's Apple Intelligence Edge Glow Effect on Mac

> Source: <https://dev.to/vector4wang/i-recreated-iphones-apple-intelligence-edge-glow-effect-on-mac-57f5>
> Published: 2026-06-18 16:29:16+00:00

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`

:

``` js
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`

:

``` js
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:

``` js
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:

``` js
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.

``` js
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:

``` js
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:

``` js
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:

``` php
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:

``` js
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!
