8a8dd98e06
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
6.4 KiB
Swift
169 lines
6.4 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
// MARK: - BroV home: two floors of flat app pills
|
|
//
|
|
// Top floor: two empty slots, to be filled later.
|
|
// Bottom floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch). The bot keeps its spot on the left.
|
|
|
|
struct DesktopAppSlot: Identifiable {
|
|
let id: String
|
|
let name: String
|
|
/// Tried in order: the first installed one wins.
|
|
let bundleIds: [String]
|
|
let color: String
|
|
let downloadURL: String
|
|
}
|
|
|
|
enum DesktopApps {
|
|
static let claude = DesktopAppSlot(id: "app_claude", name: "Claude",
|
|
bundleIds: ["com.anthropic.claudefordesktop"],
|
|
color: "#D97757", downloadURL: "https://claude.ai/download")
|
|
static let chatGPT = DesktopAppSlot(id: "app_chatgpt", name: "ChatGPT",
|
|
// New ChatGPT app first (its id is com.openai.codex), Classic as fallback.
|
|
bundleIds: ["com.openai.codex", "com.openai.chat"],
|
|
color: "#10A37F", downloadURL: "https://chatgpt.com/download")
|
|
}
|
|
|
|
struct AppGridView: View {
|
|
@ObservedObject var state: AppState
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
VStack(spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
EmptyPill()
|
|
EmptyPill()
|
|
}
|
|
HStack(spacing: 8) {
|
|
AppPill(app: DesktopApps.claude)
|
|
AppPill(app: DesktopApps.chatGPT)
|
|
}
|
|
}
|
|
.padding(.leading, 108)
|
|
.padding(.trailing, 14)
|
|
.padding(.vertical, 6)
|
|
|
|
#if !APPSTORE
|
|
if state.showingPlanDetail {
|
|
CardBackground(wash: nil)
|
|
ClaudePlanCardView(usage: state.claudePlanUsage)
|
|
.transition(.opacity)
|
|
}
|
|
#endif
|
|
}
|
|
#if !APPSTORE
|
|
.onChange(of: state.mode) { _, m in
|
|
if m != .expanded { state.showingPlanDetail = false }
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - App pill
|
|
|
|
struct AppPill: View {
|
|
let app: DesktopAppSlot
|
|
@State private var appURL: URL?
|
|
@State private var isRunning = false
|
|
@State private var isHovered = false
|
|
|
|
private var status: String {
|
|
if appURL == nil { return "не установлено" }
|
|
return isRunning ? "запущено" : "не запущено"
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: open) {
|
|
HStack(spacing: 10) {
|
|
icon
|
|
.frame(width: 26, height: 26)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(app.name)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(Color(hex: "#F5F6F8"))
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
|
|
.frame(width: 5, height: 5)
|
|
Text(status)
|
|
.font(.system(size: 10.5))
|
|
.foregroundColor(Color(hex: "#8E939C"))
|
|
}
|
|
}
|
|
Spacer(minLength: 4)
|
|
Image(systemName: "arrow.up.right")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D"))
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(isHovered ? Color(hex: app.color).opacity(0.16) : Color(hex: "#0E0F11"))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1)
|
|
)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.scaleEffect(isHovered ? 1.02 : 1)
|
|
.onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } }
|
|
.onAppear(perform: refresh)
|
|
.onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification)) { _ in refresh() }
|
|
.onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() }
|
|
}
|
|
|
|
@ViewBuilder private var icon: some View {
|
|
if let appURL {
|
|
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
|
|
.resizable()
|
|
.interpolation(.high)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(Color(hex: app.color))
|
|
.overlay(Text(String(app.name.prefix(1)))
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundColor(.white))
|
|
}
|
|
}
|
|
|
|
/// The first installed bundle id of the slot.
|
|
private var bundleId: String? {
|
|
app.bundleIds.first { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) != nil }
|
|
}
|
|
|
|
private func refresh() {
|
|
appURL = bundleId.flatMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) }
|
|
isRunning = NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == bundleId }
|
|
}
|
|
|
|
private func open() {
|
|
SoundEngine.shared.play("blip")
|
|
if let running = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleId }) {
|
|
running.activate(options: .activateIgnoringOtherApps)
|
|
} else if let appURL {
|
|
NSWorkspace.shared.openApplication(at: appURL, configuration: .init(), completionHandler: nil)
|
|
} else if let url = URL(string: app.downloadURL) {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty slot
|
|
|
|
struct EmptyPill: View {
|
|
var body: some View {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
|
.overlay(
|
|
Text("пусто")
|
|
.font(.system(size: 10.5))
|
|
.foregroundColor(Color(hex: "#4A4E55"))
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|