fix(mac): dim menubar icon like trimmy
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user