diff --git a/Sources/Secretive/Views/Configuration/ConfigurationItemView.swift b/Sources/Secretive/Views/Configuration/ConfigurationItemView.swift new file mode 100644 index 0000000..2c8520b --- /dev/null +++ b/Sources/Secretive/Views/Configuration/ConfigurationItemView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct ConfigurationItemView: View { + + enum Action: Hashable { + case copy(String) + case revealInFinder(String) + } + + let title: LocalizedStringResource + let content: Content + let action: Action? + + init(title: LocalizedStringResource, value: String, action: Action? = nil) where Content == Text { + self.title = title + self.content = Text(value) + .font(.subheadline) + .foregroundStyle(.secondary) + self.action = action + } + + init(title: LocalizedStringResource, action: Action? = nil, content: () -> Content) { + self.title = title + self.content = content() + self.action = action + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(title) + Spacer() + switch action { + case .copy(let string): + Button(.copyableClickToCopyButton, systemImage: "document.on.document") { + NSPasteboard.general.declareTypes([.string], owner: nil) + NSPasteboard.general.setString(string, forType: .string) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + case .revealInFinder(let rawPath): + Button(.revealInFinderButton, systemImage: "folder") { + // All foundation-based normalization methods replace this with the container directly. + let processedPath = rawPath.replacingOccurrences(of: "~", with: "/Users/\(NSUserName())") + let url = URL(filePath: processedPath) + let folder = url.deletingLastPathComponent().path() + NSWorkspace.shared.selectFile(processedPath, inFileViewerRootedAtPath: folder) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + case nil: + EmptyView() + } + } + content + } + } +} + diff --git a/Sources/Secretive/Views/Secrets/EmptyStoreView.swift b/Sources/Secretive/Views/Secrets/EmptyStoreView.swift new file mode 100644 index 0000000..3f0bd81 --- /dev/null +++ b/Sources/Secretive/Views/Secrets/EmptyStoreView.swift @@ -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 diff --git a/Sources/Secretive/Views/Secrets/NoStoresView.swift b/Sources/Secretive/Views/Secrets/NoStoresView.swift new file mode 100644 index 0000000..497138d --- /dev/null +++ b/Sources/Secretive/Views/Secrets/NoStoresView.swift @@ -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 diff --git a/Sources/Secretive/Views/Views/ContentView.swift b/Sources/Secretive/Views/Views/ContentView.swift new file mode 100644 index 0000000..933013d --- /dev/null +++ b/Sources/Secretive/Views/Views/ContentView.swift @@ -0,0 +1,222 @@ +import SwiftUI +import SecretKit +import SecureEnclaveSecretKit +import SmartCardSecretKit +import Brief + +struct ContentView: View { + + @Binding var showingCreation: Bool + @Binding var runningSetup: Bool + @Binding var hasRunSetup: Bool + @State var showingAgentInfo = false + @State var activeSecret: AnySecret? + @Environment(\.colorScheme) var colorScheme + + @Environment(\.secretStoreList) private var storeList + @Environment(\.updater) private var updater: any UpdaterProtocol + @Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol + + @State private var selectedUpdate: Release? + @State private var showingAppPathNotice = false + + var body: some View { + VStack { + if storeList.anyAvailable { + StoreListView(activeSecret: $activeSecret) + } else { + NoStoresView() + } + } + .frame(minWidth: 640, minHeight: 320) + .toolbar { + toolbarItem(updateNoticeView, id: "update") + toolbarItem(runningOrRunSetupView, id: "setup") + toolbarItem(appPathNoticeView, id: "appPath") + toolbarItem(newItemView, id: "new") + } + .sheet(isPresented: $runningSetup) { + SetupView(setupComplete: $hasRunSetup) + } + } + +} + +extension ContentView { + + + @ToolbarContentBuilder + func toolbarItem(_ view: some View, id: String) -> some ToolbarContent { + if #available(macOS 26.0, *) { + ToolbarItem(id: id) { view } + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(id: id) { view } + } + } + + var needsSetup: Bool { + runningSetup || !hasRunSetup + } + + /// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message + /// These two are mutually exclusive + @ViewBuilder + var runningOrRunSetupView: some View { + if needsSetup { + setupNoticeView + } else { + agentStatusToolbarView + } + } + + var updateNoticeContent: (LocalizedStringResource, Color)? { + guard let update = updater.update else { return nil } + if update.critical { + return (.updateCriticalNoticeTitle, .red) + } else { + if updater.testBuild { + return (.updateTestNoticeTitle, .blue) + } else { + return (.updateNormalNoticeTitle, .orange) + } + } + } + + @ViewBuilder + var updateNoticeView: some View { + if let update = updater.update, let (text, color) = updateNoticeContent { + Button(action: { + selectedUpdate = update + }, label: { + Text(text) + .font(.headline) + .foregroundColor(.white) + }) + .buttonStyle(ToolbarButtonStyle(color: color)) + .sheet(item: $selectedUpdate) { update in + UpdateDetailView(update: update) + } + } + } + + @ViewBuilder + var newItemView: some View { + if storeList.modifiableStore?.isAvailable ?? false { + Button(.appMenuNewSecretButton, systemImage: "plus") { + showingCreation = true + } + .menuButton() + .sheet(isPresented: $showingCreation) { + if let modifiable = storeList.modifiableStore { + CreateSecretView(store: modifiable) { created in + if let created { + activeSecret = created + } + } + } + } + } + } + + @ViewBuilder + var setupNoticeView: some View { + Button(action: { + runningSetup = true + }, label: { + if !hasRunSetup { + Text(.agentSetupNoticeTitle) + .font(.headline) + } + }) + .buttonStyle(ToolbarButtonStyle(color: .orange)) + } + + @ViewBuilder + var agentStatusToolbarView: some View { + Button(action: { + showingAgentInfo = true + }, label: { + HStack { + if agentStatusChecker.running { + Text(.agentRunningNoticeTitle) + .font(.headline) + .foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white) + Circle() + .frame(width: 10, height: 10) + .foregroundColor(Color.green) + } else { + Text(.agentNotRunningNoticeTitle) + .font(.headline) + Circle() + .frame(width: 10, height: 10) + .foregroundColor(Color.red) + } + } + }) + .buttonStyle( + ToolbarButtonStyle( + lightColor: agentStatusChecker.running ? .black.opacity(0.05) : .red.opacity(0.75), + darkColor: agentStatusChecker.running ? .white.opacity(0.05) : .red.opacity(0.5), + ) + ) + .popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { + AgentStatusView() + } + } + + @ViewBuilder + var appPathNoticeView: some View { + if !ApplicationDirectoryController().isInApplicationsDirectory { + Button(action: { + showingAppPathNotice = true + }, label: { + Group { + Text(.appNotInApplicationsNoticeTitle) + } + .font(.headline) + .foregroundColor(.white) + }) + .buttonStyle(ToolbarButtonStyle(color: .orange)) + .popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { + VStack { + Image(systemName: "exclamationmark.triangle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64) + Text(.appNotInApplicationsNoticeDetailDescription) + .frame(maxWidth: 300) + } + .padding() + } + } + } + + var attachmentAnchor: PopoverAttachmentAnchor { + .rect(.bounds) + } + +} + +#if DEBUG + +struct ContentView_Previews: PreviewProvider { + + static var previews: some View { + Group { + // Empty on modifiable and nonmodifiable + ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true)) + .environment(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)])) + .environment(PreviewUpdater()) + + // 5 items on modifiable and nonmodifiable + ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true)) + .environment(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()])) + .environment(PreviewUpdater()) + } + + } +} + +#endif +