New setup (#657)

* WIP

* WIP

* WIP

* Tweaks.

* WIP

* WIP

* WIP

* WIP

* WIP

* Cleanup

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* REmove setup menu item

* WIP

* .

* .

* .

* Cleaup.
This commit is contained in:
Max Goedjen
2025-09-03 00:20:24 -07:00
committed by GitHub
parent ddcb2a36ec
commit 147f4d9908
37 changed files with 1856 additions and 932 deletions

View File

@@ -0,0 +1,151 @@
import SwiftUI
import SecretKit
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
@State var store: StoreType
@Environment(\.dismiss) private var dismiss
var createdSecret: (AnySecret?) -> Void
@State private var name = ""
@State private var keyAttribution = ""
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
@State private var keyType: KeyType?
@State var advanced = false
@State var errorText: String?
private var authenticationOptions: [AuthenticationRequirement] {
if advanced || authenticationRequirement == .biometryCurrent {
[.presenceRequired, .notRequired, .biometryCurrent]
} else {
[.presenceRequired, .notRequired]
}
}
var body: some View {
VStack(alignment: .trailing) {
Form {
Section {
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
VStack(alignment: .leading, spacing: 10) {
Picker(.createSecretRequireAuthenticationTitle, selection: $authenticationRequirement) {
ForEach(authenticationOptions) { option in
HStack {
switch option {
case .notRequired:
Image(systemName: "bell")
Text(.createSecretNotifyTitle)
case .presenceRequired:
Image(systemName: "lock")
Text(.createSecretRequireAuthenticationTitle)
case .biometryCurrent:
Image(systemName: "lock.trianglebadge.exclamationmark.fill")
Text(.createSecretRequireAuthenticationBiometricCurrentTitle)
case .unknown:
EmptyView()
}
}
.tag(option)
}
}
Group {
switch authenticationRequirement {
case .notRequired:
Text(.createSecretNotifyDescription)
case .presenceRequired:
Text(.createSecretRequireAuthenticationDescription)
case .biometryCurrent:
Text(.createSecretRequireAuthenticationBiometricCurrentDescription)
case .unknown:
EmptyView()
}
}
.font(.subheadline)
.foregroundStyle(.secondary)
if authenticationRequirement == .biometryCurrent {
Text(.createSecretBiometryCurrentWarning)
.padding(.horizontal, 10)
.padding(.vertical, 3)
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
}
}
}
if advanced {
Section {
VStack {
Picker(.createSecretKeyTypeLabel, selection: $keyType) {
ForEach(store.supportedKeyTypes, id: \.self) { option in
Text(String(describing: option))
.tag(option)
.font(.caption)
}
}
if keyType?.algorithm == .mldsa {
Text(.createSecretMldsaWarning)
.padding(.horizontal, 10)
.padding(.vertical, 3)
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
}
}
VStack(alignment: .leading) {
TextField(.createSecretKeyAttributionLabel, text: $keyAttribution, prompt: Text(verbatim: "test@example.com"))
Text(.createSecretKeyAttributionDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
if let errorText {
Section {
} footer: {
Text(verbatim: errorText)
.errorStyle()
}
}
}
HStack {
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
.toggleStyle(.button)
Spacer()
Button(.createSecretCancelButton, role: .cancel) {
dismiss()
}
Button(.createSecretCreateButton, action: save)
.keyboardShortcut(.return)
.primaryButton()
.disabled(name.isEmpty)
}
.padding()
}
.onAppear {
keyType = store.supportedKeyTypes.first
}
.formStyle(.grouped)
}
func save() {
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
Task {
do {
let new = try await store.create(
name: name,
attributes: .init(
keyType: keyType!,
authentication: authenticationRequirement,
publicKeyAttribution: attribution
)
)
createdSecret(AnySecret(new))
dismiss()
} catch {
errorText = error.localizedDescription
}
}
}
}
#Preview {
CreateSecretView(store: Preview.StoreModifiable()) { _ in }
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
import SecretKit
extension View {
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ secret: AnySecret, _ store: AnySecretStoreModifiable?, dismissalBlock: @escaping (Bool) -> ()) -> some View {
modifier(DeleteSecretConfirmationModifier(isPresented: isPresented, secret: secret, store: store, dismissalBlock: dismissalBlock))
}
}
struct DeleteSecretConfirmationModifier: ViewModifier {
var isPresented: Binding<Bool>
var secret: AnySecret
var store: AnySecretStoreModifiable?
var dismissalBlock: (Bool) -> ()
@State var confirmedSecretName = ""
@State private var errorText: String?
func body(content: Content) -> some View {
content
.confirmationDialog(
.deleteConfirmationTitle(secretName: secret.name),
isPresented: isPresented,
titleVisibility: .visible,
actions: {
TextField(secret.name, text: $confirmedSecretName)
if let errorText {
Text(verbatim: errorText)
.errorStyle()
}
Button(.deleteConfirmationDeleteButton, action: delete)
.disabled(confirmedSecretName != secret.name)
Button(.deleteConfirmationCancelButton, role: .cancel) {
dismissalBlock(false)
}
},
message: {
Text(.deleteConfirmationDescription(secretName: secret.name, confirmSecretName: secret.name))
}
)
.dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill"))
.onExitCommand {
dismissalBlock(false)
}
}
func delete() {
Task {
do {
try await store!.delete(secret: secret)
dismissalBlock(true)
} catch {
errorText = error.localizedDescription
}
}
}
}

View File

@@ -0,0 +1,67 @@
import SwiftUI
import SecretKit
struct EditSecretView<StoreType: SecretStoreModifiable>: View {
let store: StoreType
let secret: StoreType.SecretType
let dismissalBlock: (_ renamed: Bool) -> ()
@State private var name: String
@State private var publicKeyAttribution: String
@State var errorText: String?
init(store: StoreType, secret: StoreType.SecretType, dismissalBlock: @escaping (Bool) -> ()) {
self.store = store
self.secret = secret
self.dismissalBlock = dismissalBlock
name = secret.name
publicKeyAttribution = secret.publicKeyAttribution ?? ""
}
var body: some View {
VStack(alignment: .trailing) {
Form {
Section {
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
VStack(alignment: .leading) {
TextField(.createSecretKeyAttributionLabel, text: $publicKeyAttribution, prompt: Text(verbatim: "test@example.com"))
Text(.createSecretKeyAttributionDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
}
} footer: {
if let errorText {
Text(verbatim: errorText)
.errorStyle()
}
}
}
HStack {
Button(.editCancelButton) {
dismissalBlock(false)
}
.keyboardShortcut(.cancelAction)
Button(.editSaveButton, action: rename)
.disabled(name.isEmpty)
.keyboardShortcut(.return)
.primaryButton()
}
.padding()
}
.formStyle(.grouped)
}
func rename() {
var attributes = secret.attributes
attributes.publicKeyAttribution = publicKeyAttribution.isEmpty ? nil : publicKeyAttribution
Task {
do {
try await store.update(secret: secret, name: name, attributes: attributes)
dismissalBlock(true)
} catch {
errorText = error.localizedDescription
}
}
}
}

View File

@@ -0,0 +1,71 @@
import SwiftUI
import SecretKit
struct EmptyStoreView: View {
@State var store: AnySecretStore?
var body: some View {
if store is AnySecretStoreModifiable {
EmptyStoreModifiableView()
} else {
EmptyStoreImmutableView()
}
}
}
struct EmptyStoreImmutableView: View {
var body: some View {
VStack {
Text(.emptyStoreNonmodifiableTitle).bold()
Text(.emptyStoreNonmodifiableDescription)
Text(.emptyStoreNonmodifiableSupportedKeyTypes)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct EmptyStoreModifiableView: View {
var body: some View {
GeometryReader { windowGeometry in
VStack {
GeometryReader { g in
Path { path in
path.move(to: CGPoint(x: g.size.width / 2, y: g.size.height))
path.addCurve(to:
CGPoint(x: g.size.width * (3/4), y: g.size.height * (1/2)), control1:
CGPoint(x: g.size.width / 2, y: g.size.height * (1/2)), control2:
CGPoint(x: g.size.width * (3/4), y: g.size.height * (1/2)))
path.addCurve(to:
CGPoint(x: g.size.width - 13, y: 0), control1:
CGPoint(x: g.size.width - 13 , y: g.size.height * (1/2)), control2:
CGPoint(x: g.size.width - 13, y: 0))
}.stroke(style: StrokeStyle(lineWidth: 5, lineCap: .round))
Path { path in
path.move(to: CGPoint(x: g.size.width - 23, y: 0))
path.addLine(to: CGPoint(x: g.size.width - 13, y: -10))
path.addLine(to: CGPoint(x: g.size.width - 3, y: 0))
}.fill()
}.frame(height: (windowGeometry.size.height/2) - 20).padding()
Text(.emptyStoreModifiableClickHereTitle).bold()
Text(.emptyStoreModifiableClickHereDescription)
Spacer()
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
#if DEBUG
struct EmptyStoreModifiableView_Previews: PreviewProvider {
static var previews: some View {
Group {
EmptyStoreImmutableView()
EmptyStoreModifiableView()
}
}
}
#endif

View File

@@ -0,0 +1,24 @@
import SwiftUI
struct NoStoresView: View {
var body: some View {
VStack {
Text(.noSecureStorageTitle)
.bold()
Text(.noSecureStorageDescription)
Link(.noSecureStorageYubicoLink, destination: URL(string: "https://www.yubico.com/products/compare-yubikey-5-series/")!)
}.padding()
}
}
#if DEBUG
struct NoStoresView_Previews: PreviewProvider {
static var previews: some View {
NoStoresView()
}
}
#endif

View File

@@ -0,0 +1,42 @@
import SwiftUI
import SecretKit
struct SecretDetailView<SecretType: Secret>: View {
let secret: SecretType
private let keyWriter = OpenSSHPublicKeyWriter()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
var body: some View {
ScrollView {
Form {
Section {
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))
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: publicKeyFileStoreController.publicKeyPath(for: secret))
Spacer()
}
}
.padding()
}
.frame(minHeight: 200, maxHeight: .infinity)
}
var keyString: String {
keyWriter.openSSHString(secret: secret)
}
}
#Preview {
SecretDetailView(secret: Preview.Secret(name: "Demonstration Secret"))
}

View File

@@ -0,0 +1,55 @@
import SwiftUI
import SecretKit
struct SecretListItemView: View {
@State var store: AnySecretStore
var secret: AnySecret
@State var isDeleting: Bool = false
@State var isRenaming: Bool = false
var deletedSecret: (AnySecret) -> Void
var renamedSecret: (AnySecret) -> Void
var body: some View {
NavigationLink(value: secret) {
if secret.authenticationRequirement.required {
HStack {
Text(secret.name)
Spacer()
Image(systemName: "lock")
}
} else {
Text(secret.name)
}
}
.contextMenu {
if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) {
Image(systemName: "pencil")
Text(.secretListEditButton)
}
Button(action: { isDeleting = true }) {
Image(systemName: "trash")
Text(.secretListDeleteButton)
}
}
}
.showingDeleteConfirmation(isPresented: $isDeleting, secret, store as? AnySecretStoreModifiable) { deleted in
if deleted {
deletedSecret(secret)
}
}
.sheet(isPresented: $isRenaming) {
if let modifiable = store as? AnySecretStoreModifiable {
EditSecretView(store: modifiable, secret: secret) { renamed in
isRenaming = false
if renamed {
renamedSecret(secret)
}
}
}
}
}
}

View File

@@ -0,0 +1,64 @@
import SwiftUI
import SecretKit
struct StoreListView: View {
@Binding var activeSecret: AnySecret?
@Environment(\.secretStoreList) private var storeList
private func secretDeleted(secret: AnySecret) {
activeSecret = nextDefaultSecret
}
private func secretRenamed(secret: AnySecret) {
// Toggle so name updates in list.
activeSecret = nil
activeSecret = secret
}
var body: some View {
NavigationSplitView {
List(selection: $activeSecret) {
ForEach(storeList.stores) { store in
if store.isAvailable {
Section(header: Text(store.name)) {
ForEach(store.secrets) { secret in
SecretListItemView(
store: store,
secret: secret,
deletedSecret: secretDeleted,
renamedSecret: secretRenamed
)
}
}
}
}
}
} 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 {
EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first)
}
}
.navigationSplitViewStyle(.balanced)
.onAppear {
activeSecret = nextDefaultSecret
}
.frame(minWidth: 100, idealWidth: 240)
}
}
extension StoreListView {
private var nextDefaultSecret: AnySecret? {
return storeList.allSecrets.first
}
}