fix(mac): dim menubar icon like trimmy

This commit is contained in:
Peter Steinberger
2025-12-06 01:07:15 +01:00
parent f09390a412
commit 26e939c1eb
3 changed files with 116 additions and 27 deletions

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "606518c5b87c69959b38cf07ab1ce9ec606bb1900efdd52a82f3178a39da8881", "originHash" : "31221e39fa9e1e5f162f1fecf985ccf3c6aac31ffc427a3f9f2414be0a39b50f",
"pins" : [ "pins" : [
{ {
"identity" : "asyncxpcconnection", "identity" : "asyncxpcconnection",
@@ -9,6 +9,15 @@
"revision" : "da31dbcaa1b57949e46dcc19360b17d1a8de06bd", "revision" : "da31dbcaa1b57949e46dcc19360b17d1a8de06bd",
"version" : "1.3.0" "version" : "1.3.0"
} }
},
{
"identity" : "menubarextraaccess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/orchetect/MenuBarExtraAccess",
"state" : {
"revision" : "707dff6f55217b3ef5b6be84ced3e83511d4df5c",
"version" : "1.2.2"
}
} }
], ],
"version" : 3 "version" : 3

View File

@@ -15,6 +15,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/ChimeHQ/AsyncXPCConnection", from: "1.3.0"), .package(url: "https://github.com/ChimeHQ/AsyncXPCConnection", from: "1.3.0"),
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
], ],
targets: [ targets: [
.target( .target(
@@ -29,6 +30,7 @@ let package = Package(
dependencies: [ dependencies: [
"ClawdisIPC", "ClawdisIPC",
.product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"), .product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
], ],
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),

View File

@@ -11,11 +11,12 @@ import VideoToolbox
import ServiceManagement import ServiceManagement
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
import MenuBarExtraAccess
private let serviceName = "com.steipete.clawdis.xpc" private let serviceName = "com.steipete.clawdis.xpc"
private let launchdLabel = "com.steipete.clawdis" private let launchdLabel = "com.steipete.clawdis"
private let onboardingVersionKey = "clawdis.onboardingVersion" private let onboardingVersionKey = "clawdis.onboardingVersion"
private let currentOnboardingVersion = 1 private let currentOnboardingVersion = 2
private let pauseDefaultsKey = "clawdis.pauseEnabled" private let pauseDefaultsKey = "clawdis.pauseEnabled"
// MARK: - App model // MARK: - App model
@@ -354,6 +355,8 @@ enum ShellRunner {
struct ClawdisApp: App { struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@StateObject private var state: AppState @StateObject private var state: AppState
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
init() { init() {
_state = StateObject(wrappedValue: AppStateStore.shared) _state = StateObject(wrappedValue: AppStateStore.shared)
@@ -362,6 +365,13 @@ struct ClawdisApp: App {
var body: some Scene { var body: some Scene {
MenuBarExtra { MenuContent(state: state) } label: { CritterStatusLabel(isPaused: state.isPaused) } MenuBarExtra { MenuContent(state: state) } label: { CritterStatusLabel(isPaused: state.isPaused) }
.menuBarExtraStyle(.menu) .menuBarExtraStyle(.menu)
.menuBarExtraAccess(isPresented: $isMenuPresented) { item in
statusItem = item
applyStatusItemAppearance(paused: state.isPaused)
}
.onChange(of: state.isPaused) { _, paused in
applyStatusItemAppearance(paused: paused)
}
Settings { Settings {
SettingsRootView(state: state) SettingsRootView(state: state)
@@ -370,6 +380,11 @@ struct ClawdisApp: App {
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
.windowResizability(.contentSize) .windowResizability(.contentSize)
} }
private func applyStatusItemAppearance(paused: Bool) {
statusItem?.button?.appearsDisabled = paused
statusItem?.button?.alphaValue = paused ? 0.45 : 1.0
}
} }
private struct MenuContent: View { private struct MenuContent: View {
@@ -377,8 +392,8 @@ private struct MenuContent: View {
@Environment(\.openSettings) private var openSettings @Environment(\.openSettings) private var openSettings
var body: some View { var body: some View {
Toggle(isOn: $state.isPaused) { Toggle(isOn: activeBinding) {
Text(state.isPaused ? "Clawdis Paused" : "Pause Clawdis") Text(activeBinding.wrappedValue ? "Clawdis Active" : "Clawdis Paused")
} }
Button("Settings…") { open(tab: .general) } Button("Settings…") { open(tab: .general) }
.keyboardShortcut(",", modifiers: [.command]) .keyboardShortcut(",", modifiers: [.command])
@@ -396,6 +411,10 @@ private struct MenuContent: View {
openSettings() openSettings()
NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab) NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab)
} }
private var activeBinding: Binding<Bool> {
Binding(get: { !state.isPaused }, set: { state.isPaused = !$0 })
}
} }
private struct CritterStatusLabel: View { private struct CritterStatusLabel: View {
@@ -419,7 +438,6 @@ private struct CritterStatusLabel: View {
earWiggle: earWiggle, earWiggle: earWiggle,
isPaused: isPaused isPaused: isPaused
)) ))
.renderingMode(.template)
.frame(width: 18, height: 16) .frame(width: 18, height: 16)
.rotationEffect(.degrees(wiggleAngle), anchor: .center) .rotationEffect(.degrees(wiggleAngle), anchor: .center)
.offset(x: wiggleOffset) .offset(x: wiggleOffset)
@@ -606,7 +624,7 @@ enum CritterIconRenderer {
ctx.fillPath() ctx.fillPath()
ctx.restoreGState() ctx.restoreGState()
image.isTemplate = true image.isTemplate = false
return image return image
} }
} }
@@ -684,8 +702,9 @@ struct SettingsRootView: View {
.tabItem { Label("About", systemImage: "info.circle") } .tabItem { Label("About", systemImage: "info.circle") }
.tag(SettingsTab.about) .tag(SettingsTab.about)
} }
.padding(.horizontal, 12) .padding(12)
.padding(.vertical, 12) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onReceive(NotificationCenter.default.publisher(for: .clawdisSelectSettingsTab)) { note in .onReceive(NotificationCenter.default.publisher(for: .clawdisSelectSettingsTab)) { note in
if let tab = note.object as? SettingsTab { if let tab = note.object as? SettingsTab {
withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) {
@@ -723,7 +742,7 @@ struct SettingsRootView: View {
enum SettingsTab: CaseIterable { enum SettingsTab: CaseIterable {
case general, permissions, debug, about case general, permissions, debug, about
static let windowWidth: CGFloat = 410 static let windowWidth: CGFloat = 410
static let windowHeight: CGFloat = 480 static let windowHeight: CGFloat = 484
var title: String { var title: String {
switch self { switch self {
case .general: return "General" case .general: return "General"
@@ -752,13 +771,37 @@ extension Notification.Name {
static let clawdisSelectSettingsTab = Notification.Name("clawdisSelectSettingsTab") static let clawdisSelectSettingsTab = Notification.Name("clawdisSelectSettingsTab")
} }
@MainActor
struct SettingsToggleRow: View {
let title: String
let subtitle: String?
@Binding var binding: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Toggle(isOn: $binding) {
Text(title)
.font(.body)
}
.toggleStyle(.checkbox)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
struct GeneralSettings: View { struct GeneralSettings: View {
@ObservedObject var state: AppState @ObservedObject var state: AppState
@State private var isInstallingCLI = false @State private var isInstallingCLI = false
@State private var cliStatus: String? @State private var cliStatus: String?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 18) {
if !state.onboardingSeen { if !state.onboardingSeen {
Text("Complete onboarding to finish setup") Text("Complete onboarding to finish setup")
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
@@ -766,12 +809,21 @@ struct GeneralSettings: View {
.padding(.bottom, 2) .padding(.bottom, 2)
} }
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 12) {
Toggle(isOn: activeBinding) { Text(activeBinding.wrappedValue ? "Clawdis Active" : "Clawdis Paused") } SettingsToggleRow(
.help("Disable to stop Clawdis background helpers and notifications") title: activeBinding.wrappedValue ? "Clawdis active" : "Clawdis paused",
Toggle(isOn: $state.launchAtLogin) { Text("Launch at login") } subtitle: "Pause to stop Clawdis background helpers and notifications.",
Toggle(isOn: $state.debugPaneEnabled) { Text("Enable debug tools") } binding: activeBinding)
.help("Show the Debug tab with development utilities")
SettingsToggleRow(
title: "Launch at login",
subtitle: "Automatically start Clawdis after you sign in.",
binding: $state.launchAtLogin)
SettingsToggleRow(
title: "Enable debug tools",
subtitle: "Show the Debug tab with development utilities.",
binding: $state.debugPaneEnabled)
LabeledContent("Default sound") { LabeledContent("Default sound") {
Picker("Sound", selection: $state.defaultSound) { Picker("Sound", selection: $state.defaultSound) {
@@ -800,6 +852,7 @@ struct GeneralSettings: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
} }
private var activeBinding: Binding<Bool> { private var activeBinding: Binding<Bool> {
@@ -865,6 +918,8 @@ struct PermissionsSettings: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
} }
} }
@@ -883,6 +938,8 @@ struct DebugSettings: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
} }
private func relaunch() { private func relaunch() {
@@ -905,17 +962,17 @@ struct AboutSettings: View {
@State private var iconHover = false @State private var iconHover = false
var body: some View { var body: some View {
VStack(spacing: 14) { VStack(spacing: 8) {
let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0) let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0)
Button { Button {
if let url = URL(string: "https://github.com/steipete/warelay") { if let url = URL(string: "https://github.com/steipete/clawdis") {
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} }
} label: { } label: {
Image(nsImage: appIcon) Image(nsImage: appIcon)
.resizable() .resizable()
.frame(width: 88, height: 88) .frame(width: 88, height: 88)
.cornerRadius(18) .cornerRadius(16)
.shadow(color: iconHover ? .accentColor.opacity(0.25) : .clear, radius: 8) .shadow(color: iconHover ? .accentColor.opacity(0.25) : .clear, radius: 8)
.scaleEffect(iconHover ? 1.06 : 1.0) .scaleEffect(iconHover ? 1.06 : 1.0)
} }
@@ -924,36 +981,44 @@ struct AboutSettings: View {
withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { iconHover = hover } withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { iconHover = hover }
} }
VStack(spacing: 4) { VStack(spacing: 3) {
Text("Clawdis") Text("Clawdis")
.font(.title3.bold()) .font(.title3.bold())
Text("Version \(versionString)") Text("Version \(versionString)")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if let buildTimestamp {
Text("Built \(buildTimestamp)")
.font(.footnote)
.foregroundStyle(.secondary)
}
Text("Menu bar companion for notifications, screenshots, and privileged agent actions.") Text("Menu bar companion for notifications, screenshots, and privileged agent actions.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 24) .padding(.horizontal, 18)
} }
VStack(alignment: .center, spacing: 8) { VStack(alignment: .center, spacing: 6) {
AboutLinkRow(icon: "chevron.left.slash.chevron.right", title: "GitHub", url: "https://github.com/steipete/warelay") AboutLinkRow(icon: "chevron.left.slash.chevron.right", title: "GitHub", url: "https://github.com/steipete/clawdis")
AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me") AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me")
AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete") AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete")
AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me") AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me")
} }
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.padding(.vertical, 10) .padding(.vertical, 10)
Text("© 2025 Peter Steinberger — MIT License.") Text("© 2025 Peter Steinberger — MIT License.")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.top, 4)
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 18) .padding(.top, 4)
.padding(.horizontal, 18) .padding(.horizontal, 24)
.padding(.bottom, 22) .padding(.bottom, 24)
} }
private var versionString: String { private var versionString: String {
@@ -961,6 +1026,19 @@ struct AboutSettings: View {
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
return build.map { "\(version) (\($0))" } ?? version return build.map { "\(version) (\($0))" } ?? version
} }
private var buildTimestamp: String? {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "ClawdisBuildTimestamp") as? String else { return nil }
let parser = ISO8601DateFormatter()
parser.formatOptions = [.withInternetDateTime]
guard let date = parser.date(from: raw) else { return raw }
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
formatter.locale = .current
return formatter.string(from: date)
}
} }
@MainActor @MainActor