Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ A menu-bar-only AppKit application that supervises a local ForgeCode WebSocket s

## Prerequisites

Building and testing the app needs Xcode 15 or later (Swift 5.9, macOS 13 SDK). Semantic-version parsing and comparison are implemented locally, and the package has no remote dependencies or `Package.resolved` remote pins. Before the first build, vendor the Sparkle updater framework once:
Building and testing the app needs **Xcode 26 or later** on a macOS 26 host. The app target references macOS 26 SDK symbols (`NSGlassEffectView`, `prefersCompactControlSizeMetrics`) behind `if #available(macOS 26.0, *)` checks, so it does not compile against an older SDK, and `swift test` builds every target including the app. The runtime deployment target is unchanged at macOS 13: those symbols are weak-linked and the app falls back to `NSVisualEffectView` on macOS 13-15. See `docs/liquid-glass-adoption.md`.

Semantic-version parsing and comparison are implemented locally, and the package has no remote dependencies or `Package.resolved` remote pins. Before the first build, vendor the Sparkle updater framework once:

```sh
tools/fetch-sparkle.sh
Expand Down Expand Up @@ -61,6 +63,12 @@ The body shows the commands directly: Launch at Login and its approval path, err

The service is not a user-facing toggle. It starts with the app and stops when the app quits, so there is no Run or Restart command; quitting and reopening ForgeCode restarts it.

### Panel material

The panel's backdrop is selected at runtime. On macOS 26 it is Liquid Glass (`NSGlassEffectView`, `.regular` style), matching the system's own menu bar popovers; on macOS 13-15 it is the previous `NSVisualEffectView` `.menu` material. Corner radius, the row hover highlight, and control-size metrics follow the active backend. `MenuBackdrop` owns that choice, and `docs/liquid-glass-adoption.md` records why.

Because the effect depends on what is behind the window, it cannot be reviewed from source or from the unit tests; it has to be seen running on each OS.

### Console origin

The **Open** link uses this default origin:
Expand Down
28 changes: 19 additions & 9 deletions macos/Sources/ForgeMenuBar/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
statusItem.button?.target = self
statusItem.button?.action = #selector(togglePopover(_:))
statusItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
// Built once. The image never varies, so rebuilding it on every
// snapshot would re-rasterise the bezier path for no visible change.
// `statusImage()` already marks it as a template.
statusItem.button?.image = ForgeCodeLogo.statusImage()
updateStatusItem(serviceController.snapshot)

popoverController = PopoverController()
Expand Down Expand Up @@ -162,8 +166,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let appItem = NSMenuItem()
let appMenu = NSMenu(title: "ForgeCode")
if let updaterController {
// Kept verbatim in sync with the panel row in MenuRenderer: both invoke
// the same Sparkle check, and the label is scoped to the app so it is
// not read as covering the separately-updated server runtime.
let checkForUpdates = NSMenuItem(
title: "Check for Updates",
title: "Update ForgeCode App",
action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)),
keyEquivalent: ""
)
Expand Down Expand Up @@ -265,16 +272,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
popoverController.toggle(relativeTo: sender)
}

/// The status icon is deliberately constant: a single template image with no
/// tint, so it always matches the menu bar like every other status item.
///
/// Phase-derived tinting used to live in `updateStatusItem`, but it made the
/// icon visibly change shade during every launch. The first paint happens
/// before `serviceController.start()`, when the snapshot still holds its
/// default `.stopped` phase, so the icon appeared dimmed
/// (`.secondaryLabelColor`), flipped to `.controlAccentColor` while
/// installing/starting, then settled untinted at `.ready` — read as the
/// logo "changing colour on its own". Phase is still surfaced through the
/// tooltip, the accessibility label, and the panel itself.
private func updateStatusItem(_ snapshot: ServiceSnapshot) {
guard let button = statusItem.button else { return }
switch snapshot.phase {
case .ready: button.contentTintColor = nil
case .installing, .starting, .restarting: button.contentTintColor = .controlAccentColor
case .installationFailed, .failed: button.contentTintColor = .systemOrange
case .disabled, .stopped: button.contentTintColor = .secondaryLabelColor
}
button.image = ForgeCodeLogo.statusImage()
button.image?.isTemplate = true
let presentation = PopoverPresentation.make(snapshot: snapshot)
let accessibilityStatus = "\(presentation.serviceTitle), \(presentation.serviceDetail)"
button.setAccessibilityLabel(accessibilityStatus)
Expand Down
111 changes: 111 additions & 0 deletions macos/Sources/ForgeMenuBar/MenuBackdrop.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import AppKit

/// The material behind the menu panel.
///
/// macOS 26 introduced Liquid Glass, which is what the system's own menu bar
/// popovers use. `NSVisualEffectView` was not deprecated by that release, but it
/// also did not adopt the new material: `.menu` still renders the pre-26 frosted
/// blur. Left alone, the panel would read as a macOS 15 menu sitting next to
/// system surfaces that had moved on, so the backend is chosen per OS.
///
/// See `docs/liquid-glass-adoption.md` for the decision record.
enum MenuBackdrop {
/// Which material is in use. Callers whose own styling has to follow the
/// backdrop -- corner radius and the row highlight -- branch on this rather
/// than repeating the availability check.
enum Backend {
case glass
case visualEffect

static var active: Backend {
if #available(macOS 26.0, *) { return .glass }
return .visualEffect
}
}

/// Corner radius of the panel.
///
/// One value for both backends. A larger radius was tried on glass on the
/// theory that macOS 26 rounds popovers more generously, but at this panel's
/// size it read as overly bubbled rather than native, so both paths keep the
/// 10pt that matches the system menu silhouette.
static let cornerRadius: CGFloat = 10

/// Wraps `content` in the backdrop and returns the view to use as the
/// panel's root.
///
/// `content` must be the whole panel body. On the glass path it becomes the
/// glass view's `contentView`, which is the only placement AppKit makes
/// guarantees about: arbitrary subviews of an `NSGlassEffectView` have
/// undefined z-order with respect to the effect, and a glass view installed
/// *behind* content as a sibling is explicitly wrong.
static func makeRoot(content: NSView) -> NSView {
content.translatesAutoresizingMaskIntoConstraints = false
let radius = cornerRadius

if #available(macOS 26.0, *) {
let glass = NSGlassEffectView()
glass.translatesAutoresizingMaskIntoConstraints = false
// `.regular` is the variant the HIG names for popovers: it adapts
// luminosity to keep text legible. `.clear` is for floating over
// photo/video content and would wash these rows out.
glass.style = .regular
// Deliberately the view's own `cornerRadius` and not a layer mask.
// Glass samples a region larger than itself to refract what is
// behind the window; clipping the layer clips that sampling region
// and flattens the effect into a plain blur with hard corners.
glass.cornerRadius = radius
// `cornerRadius` alone only rounds the glass; the content view and
// its rows keep painting square right out to the bounds, so the
// corners read as pointy tabs poking past the curve and any row
// highlight squares off the top and bottom of the panel. Clipping
// is what makes the content follow the same silhouette.
glass.clipsToBounds = true
// Assigning `contentView` also installs the Auto Layout ties
// between the two, so the glass tracks the body's geometry.
glass.contentView = content
return glass
}

let backdrop = NSVisualEffectView()
// `.menu` is the material the pre-26 system menus themselves use, so
// the panel picks up the same translucency and blur rather than the
// flatter, heavier `.hudWindow` wash.
backdrop.material = .menu
backdrop.blendingMode = .behindWindow
backdrop.state = .active
// Deliberately no explicit `appearance`: leaving it nil lets the view
// inherit the system light/dark setting, matching every other menu bar
// panel. Pinning it to .darkAqua forced a dark menu onto light-mode
// users.
backdrop.wantsLayer = true
// The mask (rather than layer.cornerRadius) is what clips a
// behind-window blend correctly; a plain cornerRadius leaves the
// blurred backdrop showing square corners underneath. This is the
// legacy path only -- see the glass branch above.
backdrop.maskImage = roundedMask(radius: radius)
backdrop.translatesAutoresizingMaskIntoConstraints = false
backdrop.addSubview(content)
NSLayoutConstraint.activate([
content.topAnchor.constraint(equalTo: backdrop.topAnchor),
content.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor),
content.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor),
content.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor)
])
return backdrop
}

/// A resizable rounded-rect mask. The center is stretched, so one image
/// serves every panel height.
private static func roundedMask(radius: CGFloat) -> NSImage {
let edge = radius * 2 + 1
let image = NSImage(size: NSSize(width: edge, height: edge), flipped: false) { rect in
NSColor.black.setFill()
NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill()
return true
}
image.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius)
image.resizingMode = .stretch
return image
}
}
Loading
Loading