cd /news/developer-tools/i-recreated-iphone-s-apple-intellige… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-32914] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=↑ positive

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

A developer created EdgeGlow, a free open-source macOS menu bar app that recreates the iPhone's Apple Intelligence edge glow effect on Mac. The app uses a timer-based animation system to avoid state loss when windows are hidden, and employs a 20-segment gradient with a 4-layer glow stack to mimic the iridescent effect.

read6 min views1 publishedJun 18, 2026

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!

── more in #developer-tools 4 stories Β· sorted by recency
── more on @apple 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/i-recreated-iphone-s…] indexed:0 read:6min 2026-06-18 Β· β€”