mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-05-07 16:08:58 +02:00
Certificate UI/Import (#798)
* Sketching out. * WIP * WIP * Dump * Apply stash * Merge + WIP * UI * More WIP * Agent config * UI cleanup * Restore dirty files * XPC * Edit/delete * UI fixes * Cleanup * Change id for OpenSSHCertificate to hex of md5 * Fix runtime warning for confirmation dialog * Mark strings as reviewed * Cleanup * Fix agent tests
This commit is contained in:
68
Sources/Secretive/Views/Secrets/CertificateDetailView.swift
Normal file
68
Sources/Secretive/Views/Secrets/CertificateDetailView.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
import Common
|
||||
import SSHProtocolKit
|
||||
|
||||
struct CertificateDetailView: View {
|
||||
|
||||
let certificate: OpenSSHCertificate
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Form {
|
||||
Section {
|
||||
CopyableView(
|
||||
title: .certificateDetailKeyIdLabel,
|
||||
image: Image(systemName: "person.text.rectangle"),
|
||||
text: certificate.keyID
|
||||
)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(
|
||||
title: .certificateDetailSerialLabel,
|
||||
image: Image(systemName: "number.circle"),
|
||||
text: certificate.serial.formatted()
|
||||
)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
if let validityRange = certificate.validityRange {
|
||||
let epoch = Date(timeIntervalSince1970: 0)
|
||||
let end = Date(timeIntervalSince1970: TimeInterval(UInt64.max))
|
||||
switch (validityRange.lowerBound, validityRange.upperBound) {
|
||||
case (epoch, end):
|
||||
EmptyView()
|
||||
case (epoch, let otherEnd):
|
||||
MultilineInfoView(title: .certificateDetailValidUntilLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherEnd.formatted()])
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
case (let otherStart, end):
|
||||
MultilineInfoView(title: .certificateDetailValidAfterLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherStart.formatted()])
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
default:
|
||||
MultilineInfoView(title: .certificateDetailValidityRangeLabel, image: Image(systemName: "calendar.badge.clock"), items: [validityRange.formatted()])
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
}
|
||||
}
|
||||
if !certificate.principals.isEmpty {
|
||||
MultilineInfoView(title: .certificateDetailPrincipalsLabel, image: Image(systemName: "person.2"), items: certificate.principals)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
}
|
||||
CopyableView(
|
||||
title: .certificateDetailPathLabel,
|
||||
image: Image(systemName: "checkmark.seal.text.page"),
|
||||
text: URL.certificatePath(for: certificate, in: URL.certificatesDirectory),
|
||||
showRevealInFinder: true
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minHeight: 200, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
import CertificateKit
|
||||
import SSHProtocolKit
|
||||
|
||||
struct CertificateListItemView: View {
|
||||
|
||||
@Environment(\.certificateStore) private var store
|
||||
|
||||
var certificate: OpenSSHCertificate
|
||||
|
||||
@State var isDeleting: Bool = false
|
||||
@State var isRenaming: Bool = false
|
||||
|
||||
var deletedCertificate: (OpenSSHCertificate) -> Void
|
||||
var renamedCertificate: (OpenSSHCertificate) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(value: certificate) {
|
||||
Text(certificate.name)
|
||||
}
|
||||
.sheet(isPresented: $isRenaming, onDismiss: {
|
||||
renamedCertificate(certificate)
|
||||
}, content: {
|
||||
EditCertificateView(store: store, certificate: certificate)
|
||||
})
|
||||
.showingDeleteConfirmation(isPresented: $isDeleting, certificate, store) { deleted in
|
||||
if deleted {
|
||||
deletedCertificate(certificate)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: { isRenaming = true }) {
|
||||
Image(systemName: "pencil")
|
||||
Text(.secretListEditButton)
|
||||
}
|
||||
Button(action: { isDeleting = true }) {
|
||||
Image(systemName: "trash")
|
||||
Text(.secretListDeleteButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
Sources/Secretive/Views/Secrets/DeleteCertificateView.swift
Normal file
52
Sources/Secretive/Views/Secrets/DeleteCertificateView.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
import CertificateKit
|
||||
import SSHProtocolKit
|
||||
|
||||
extension View {
|
||||
|
||||
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ certificate: OpenSSHCertificate, _ store: CertificateStore, dismissalBlock: @escaping (Bool) -> ()) -> some View {
|
||||
modifier(DeleteCertificateConfirmationModifier(isPresented: isPresented, certificate: certificate, store: store, dismissalBlock: dismissalBlock))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DeleteCertificateConfirmationModifier: ViewModifier {
|
||||
|
||||
var isPresented: Binding<Bool>
|
||||
var certificate: OpenSSHCertificate
|
||||
var store: CertificateStore
|
||||
var dismissalBlock: (Bool) -> ()
|
||||
@State var confirmedSecretName = ""
|
||||
@State private var errorText: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.confirmationDialog(
|
||||
String(localized: .deleteConfirmationTitle(name: certificate.name)),
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible,
|
||||
actions: {
|
||||
Button(.deleteConfirmationDeleteButton, action: delete)
|
||||
Button(.deleteConfirmationCancelButton, role: .cancel) {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill"))
|
||||
.onExitCommand {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
}
|
||||
|
||||
func delete() {
|
||||
Task {
|
||||
do {
|
||||
try store.delete(certificate: certificate)
|
||||
dismissalBlock(true)
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,7 +21,7 @@ struct DeleteSecretConfirmationModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.confirmationDialog(
|
||||
.deleteConfirmationTitle(secretName: secret.name),
|
||||
.deleteConfirmationTitle(name: secret.name),
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible,
|
||||
actions: {
|
||||
|
||||
60
Sources/Secretive/Views/Secrets/EditCertificateView.swift
Normal file
60
Sources/Secretive/Views/Secrets/EditCertificateView.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
|
||||
struct EditCertificateView: View {
|
||||
|
||||
let store: CertificateStore
|
||||
let certificate: OpenSSHCertificate
|
||||
|
||||
@State private var name: String
|
||||
@State var errorText: String?
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
init(store: CertificateStore, certificate: OpenSSHCertificate) {
|
||||
self.store = store
|
||||
self.certificate = certificate
|
||||
name = certificate.name
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: .renameCertificateLabel), text: $name, prompt: Text(.renameCertificateNamePlaceholder))
|
||||
} footer: {
|
||||
if let errorText {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button(.editCancelButton) {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button(.editSaveButton, action: rename)
|
||||
.disabled(name.isEmpty)
|
||||
.keyboardShortcut(.return)
|
||||
.primaryButton()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
func rename() {
|
||||
Task {
|
||||
do {
|
||||
var updated = certificate
|
||||
updated.name = name
|
||||
try store.update(certificate: updated)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import SSHProtocolKit
|
||||
struct SecretDetailView<SecretType: Secret>: View {
|
||||
|
||||
let secret: SecretType
|
||||
let certificates: [OpenSSHCertificate]
|
||||
let navigateToCertificate: ((OpenSSHCertificate) -> Void)?
|
||||
|
||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||
|
||||
@@ -13,16 +15,42 @@ struct SecretDetailView<SecretType: Secret>: View {
|
||||
ScrollView {
|
||||
Form {
|
||||
Section {
|
||||
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
|
||||
CopyableView(
|
||||
title: .secretDetailSha256FingerprintLabel,
|
||||
image: Image(systemName: "touchid"),
|
||||
text: keyWriter.openSSHSHA256Fingerprint(secret: secret)
|
||||
)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: .secretDetailMd5FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
|
||||
CopyableView(
|
||||
title: .secretDetailMd5FingerprintLabel,
|
||||
image: Image(systemName: "touchid"),
|
||||
text: keyWriter.openSSHMD5Fingerprint(secret: secret)
|
||||
)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: .secretDetailPublicKeyLabel, image: Image(systemName: "key"), text: keyString)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: .secretDetailPublicKeyPathLabel, image: Image(systemName: "lock.doc"), text: URL.publicKeyPath(for: secret, in: URL.publicKeyDirectory), showRevealInFinder: true)
|
||||
CopyableView(
|
||||
title: .secretDetailPublicKeyPathLabel,
|
||||
image: Image(systemName: "lock.doc"),
|
||||
text: URL.publicKeyPath(for: secret, in: URL.publicKeyDirectory),
|
||||
showRevealInFinder: true
|
||||
)
|
||||
if !certificates.isEmpty {
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
MultilineInfoView(
|
||||
title: .secretDetailCertificatePathLabel,
|
||||
image: Image(
|
||||
systemName: "checkmark.seal.text.page"
|
||||
),
|
||||
items: certificates.map({ certificate in
|
||||
MultilineInfoView.Item(
|
||||
text: certificate.name,
|
||||
action: (Image(systemName: "chevron.forward"), { navigateToCertificate?(certificate) })
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
@@ -32,10 +60,6 @@ struct SecretDetailView<SecretType: Secret>: View {
|
||||
}
|
||||
|
||||
|
||||
var keyString: String {
|
||||
keyWriter.openSSHString(secret: secret)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
import SSHProtocolKit
|
||||
|
||||
struct StoreListView: View {
|
||||
|
||||
@Binding var activeSecret: AnySecret?
|
||||
enum StoreListSelection: Hashable {
|
||||
case secret(AnySecret)
|
||||
case certificate(OpenSSHCertificate)
|
||||
}
|
||||
|
||||
@Binding var selection: StoreListSelection?
|
||||
|
||||
@Environment(\.secretStoreList) private var storeList
|
||||
@Environment(\.certificateStore) private var certificateStore
|
||||
|
||||
private func secretDeleted(secret: AnySecret) {
|
||||
activeSecret = nextDefaultSecret
|
||||
selection = nextDefaultSecret.map(StoreListSelection.secret)
|
||||
}
|
||||
|
||||
private func secretRenamed(secret: AnySecret) {
|
||||
// Pull new version from store, so we get all updated attributes
|
||||
activeSecret = nil
|
||||
activeSecret = storeList.allSecrets.first(where: { $0.id == secret.id })
|
||||
selection = nil
|
||||
selection = storeList.allSecrets.first(where: { $0.id == secret.id }).map(StoreListSelection.secret)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $activeSecret) {
|
||||
List(selection: $selection) {
|
||||
ForEach(storeList.stores) { store in
|
||||
if store.isAvailable {
|
||||
Section(header: Text(store.name)) {
|
||||
@@ -30,29 +37,51 @@ struct StoreListView: View {
|
||||
deletedSecret: secretDeleted,
|
||||
renamedSecret: secretRenamed,
|
||||
)
|
||||
.tag(StoreListSelection.secret(secret))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !certificateStore.certificates.isEmpty {
|
||||
Section("Certificates") {
|
||||
ForEach(certificateStore.certificates) { certificate in
|
||||
CertificateListItemView(
|
||||
certificate: certificate,
|
||||
deletedCertificate: { _ in },
|
||||
renamedCertificate: { _ in }
|
||||
)
|
||||
.tag(StoreListSelection.certificate(certificate))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
if let activeSecret {
|
||||
SecretDetailView(secret: activeSecret)
|
||||
} else if let nextDefaultSecret {
|
||||
// This just means onAppear hasn't executed yet.
|
||||
// Do this to avoid a blip.
|
||||
SecretDetailView(secret: nextDefaultSecret)
|
||||
} else {
|
||||
if let modifiable = storeList.modifiableStore, modifiable.isAvailable {
|
||||
EmptyStoreView(store: modifiable)
|
||||
switch selection {
|
||||
case .secret(let secret):
|
||||
SecretDetailView(secret: secret, certificates: certificateStore.certificates(for: secret)) {
|
||||
selection = .certificate($0)
|
||||
}
|
||||
case .certificate(let certificate):
|
||||
CertificateDetailView(certificate: certificate)
|
||||
case nil:
|
||||
if let nextDefaultSecret {
|
||||
// This just means onAppear hasn't executed yet.
|
||||
// Do this to avoid a blip.
|
||||
SecretDetailView(secret: nextDefaultSecret, certificates: certificateStore.certificates(for: nextDefaultSecret)) {
|
||||
selection = .certificate($0)
|
||||
}
|
||||
} else {
|
||||
EmptyStoreView(store: storeList.stores.first(where: \.isAvailable))
|
||||
if let modifiable = storeList.modifiableStore, modifiable.isAvailable {
|
||||
EmptyStoreView(store: modifiable)
|
||||
} else {
|
||||
EmptyStoreView(store: storeList.stores.first(where: \.isAvailable))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.onAppear {
|
||||
activeSecret = nextDefaultSecret
|
||||
selection = nextDefaultSecret.map(StoreListSelection.secret)
|
||||
}
|
||||
.frame(minWidth: 100, idealWidth: 240)
|
||||
|
||||
|
||||
@@ -3,16 +3,19 @@ import SecretKit
|
||||
import SecureEnclaveSecretKit
|
||||
import SmartCardSecretKit
|
||||
import Brief
|
||||
import SSHProtocolKit
|
||||
import SharedXPCServices
|
||||
|
||||
struct ContentView: View {
|
||||
|
||||
@State var activeSecret: AnySecret?
|
||||
@State var selection: StoreListView.StoreListSelection?
|
||||
|
||||
@State private var selectedUpdate: Release?
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(\.secretStoreList) private var storeList
|
||||
@Environment(\.certificateStore) private var certificateStore
|
||||
@Environment(\.updater) private var updater
|
||||
@Environment(\.agentLaunchController) private var agentLaunchController
|
||||
|
||||
@@ -25,7 +28,7 @@ struct ContentView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
if storeList.anyAvailable {
|
||||
StoreListView(activeSecret: $activeSecret)
|
||||
StoreListView(selection: $selection)
|
||||
} else {
|
||||
NoStoresView()
|
||||
}
|
||||
@@ -42,6 +45,21 @@ struct ContentView: View {
|
||||
runningSetup = true
|
||||
}
|
||||
}
|
||||
.dropDestination(for: URL.self) { items, location in
|
||||
guard let url = items.first, url.pathExtension == "pub" else { return false }
|
||||
Task {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let parser = try await XPCCertificateParser()
|
||||
let cert = try await parser.parse(data: data)
|
||||
try certificateStore.save(certificate: cert, originalData: data)
|
||||
selection = .certificate(cert)
|
||||
} catch {
|
||||
|
||||
}
|
||||
}
|
||||
return true
|
||||
} isTargeted: { _ in }
|
||||
.focusedSceneValue(\.showCreateSecret, .init(isEnabled: !runningSetup) {
|
||||
showingCreation = true
|
||||
})
|
||||
@@ -49,7 +67,7 @@ struct ContentView: View {
|
||||
if let modifiable = storeList.modifiableStore {
|
||||
CreateSecretView(store: modifiable) { created in
|
||||
if let created {
|
||||
activeSecret = created
|
||||
selection = .secret(created)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import UniformTypeIdentifiers
|
||||
struct CopyableView: View {
|
||||
|
||||
var title: LocalizedStringResource
|
||||
var subtitle: String?
|
||||
var image: Image
|
||||
var text: String
|
||||
var showRevealInFinder = false
|
||||
|
||||
@State private var interactionState: InteractionState = .normal
|
||||
|
||||
|
||||
var content: some View {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
HStack {
|
||||
@@ -17,9 +18,16 @@ struct CopyableView: View {
|
||||
.renderingMode(.template)
|
||||
.imageScale(.large)
|
||||
.foregroundColor(primaryTextColor)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(primaryTextColor)
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(primaryTextColor)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.system(.subheadline, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if interactionState != .normal {
|
||||
HStack {
|
||||
|
||||
167
Sources/Secretive/Views/Views/MultilineInfoView.swift
Normal file
167
Sources/Secretive/Views/Views/MultilineInfoView.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct MultilineInfoView: View {
|
||||
|
||||
struct Item {
|
||||
let text: String
|
||||
let action: (Image, () -> Void)?
|
||||
}
|
||||
|
||||
var title: LocalizedStringResource
|
||||
var image: Image
|
||||
var items: [Item]
|
||||
|
||||
init(title: LocalizedStringResource, image: Image, items: [Item]) {
|
||||
self.title = title
|
||||
self.image = image
|
||||
self.items = items
|
||||
}
|
||||
|
||||
init(title: LocalizedStringResource, image: Image, items: [String]) {
|
||||
self.title = title
|
||||
self.image = image
|
||||
self.items = items.map({ Item(text: $0, action: nil) })
|
||||
}
|
||||
|
||||
@State private var interactionState: InteractionState = .normal
|
||||
@State private var interactionStateIndex: Int?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
image
|
||||
.renderingMode(.template)
|
||||
.imageScale(.large)
|
||||
.foregroundColor(primaryTextColor)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(primaryTextColor)
|
||||
Spacer()
|
||||
}
|
||||
.safeAreaPadding(20)
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { item in
|
||||
Divider()
|
||||
.ignoresSafeArea()
|
||||
.opacity(item.offset == 0 ? 1 : 0.75)
|
||||
HStack {
|
||||
Text(item.element.text)
|
||||
Spacer()
|
||||
if let (image, _) = item.element.action {
|
||||
image
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.safeAreaPadding(20)
|
||||
._background(interactionState: interactionStateIndex == item.offset ? interactionState : .normal, cornerRadius: 0)
|
||||
.onHover { hovering in
|
||||
withAnimation {
|
||||
guard item.element.action != nil else { return }
|
||||
interactionState = hovering ? .hovering : .normal
|
||||
interactionStateIndex = item.offset
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
TapGesture()
|
||||
.onEnded {
|
||||
item.element.action?.1()
|
||||
withAnimation {
|
||||
interactionState = .normal
|
||||
interactionStateIndex = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
._background(interactionState: .normal)
|
||||
.frame(minWidth: 150, maxWidth: .infinity)
|
||||
}
|
||||
|
||||
var primaryTextColor: Color {
|
||||
switch interactionState {
|
||||
case .normal, .hovering:
|
||||
return Color(.textColor)
|
||||
}
|
||||
}
|
||||
|
||||
var secondaryTextColor: Color {
|
||||
switch interactionState {
|
||||
case .normal, .hovering:
|
||||
return Color(.secondaryLabelColor)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileprivate enum InteractionState {
|
||||
case normal, hovering
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
fileprivate func _background(interactionState: InteractionState, cornerRadius: Double = 15) -> some View {
|
||||
modifier(BackgroundViewModifier(interactionState: interactionState, cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileprivate struct BackgroundViewModifier: ViewModifier {
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.appearsActive) private var appearsActive
|
||||
|
||||
let interactionState: InteractionState
|
||||
let cornerRadius: Double
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content
|
||||
// Very thin opacity lets user hover anywhere over the view, glassEffect doesn't allow.
|
||||
.background(.white.opacity(0.01), in: RoundedRectangle(cornerRadius: 15))
|
||||
.glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: cornerRadius))
|
||||
.mask(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
.shadow(color: .black.opacity(0.1), radius: 5)
|
||||
} else {
|
||||
content
|
||||
.background(backgroundColor(interactionState: interactionState))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
func backgroundColor(interactionState: InteractionState) -> Color {
|
||||
guard appearsActive else { return Color.clear }
|
||||
if #available(macOS 26.0, *) {
|
||||
let base = colorScheme == .dark ? Color(white: 0.2) : Color(white: 1)
|
||||
switch interactionState {
|
||||
case .normal:
|
||||
return base
|
||||
case .hovering:
|
||||
return base.mix(with: .accentColor, by: colorScheme == .dark ? 0.2 : 0.1)
|
||||
}
|
||||
} else {
|
||||
switch interactionState {
|
||||
case .normal:
|
||||
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
|
||||
case .hovering:
|
||||
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MultilineInfoView(title: "Multiple", image: Image(systemName: "figure.wave"), items: [
|
||||
MultilineInfoView.Item(text: "hello", action: (Image(systemName: "chevron.forward"), {})),
|
||||
MultilineInfoView.Item(text: "World", action: (Image(systemName: "chevron.forward"), {})),
|
||||
])
|
||||
.padding()
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
MultilineInfoView(title: "One", image: Image(systemName: "figure.wave"), items: ["Hello world."])
|
||||
.padding()
|
||||
}
|
||||
Reference in New Issue
Block a user