mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-03-11 03:57:23 +01:00
Merge branch 'newsetup' into menubar
This commit is contained in:
@@ -3,10 +3,11 @@ import SwiftUI
|
||||
struct PrimaryButtonModifier: ViewModifier {
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.isEnabled) var isEnabled
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// Tinted glass prominent is really hard to read on 26.0.
|
||||
if #available(macOS 26.0, *), colorScheme == .dark {
|
||||
if #available(macOS 26.0, *), colorScheme == .dark, isEnabled {
|
||||
content.buttonStyle(.glassProminent)
|
||||
} else {
|
||||
content.buttonStyle(.borderedProminent)
|
||||
@@ -17,8 +18,77 @@ struct PrimaryButtonModifier: ViewModifier {
|
||||
|
||||
extension View {
|
||||
|
||||
func primary() -> some View {
|
||||
func primaryButton() -> some View {
|
||||
modifier(PrimaryButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct MenuButtonModifier: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content
|
||||
.glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle)
|
||||
} else {
|
||||
content
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func menuButton() -> some View {
|
||||
modifier(MenuButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct NormalButtonModifier: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content.buttonStyle(.glass)
|
||||
} else {
|
||||
content.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func normalButton() -> some View {
|
||||
modifier(NormalButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DangerButtonModifier: ViewModifier {
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// Tinted glass prominent is really hard to read on 26.0.
|
||||
if #available(macOS 26.0, *), colorScheme == .dark {
|
||||
content.buttonStyle(.glassProminent)
|
||||
.tint(.red)
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
content.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func danger() -> some View {
|
||||
modifier(DangerButtonModifier())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
154
Sources/Secretive/Views/AgentStatusView.swift
Normal file
154
Sources/Secretive/Views/AgentStatusView.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AgentStatusView: View {
|
||||
|
||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||
|
||||
var body: some View {
|
||||
if agentStatusChecker.running {
|
||||
AgentRunningView()
|
||||
} else {
|
||||
AgentNotRunningView()
|
||||
}
|
||||
}
|
||||
}
|
||||
struct AgentRunningView: View {
|
||||
|
||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||
private let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
if let process = agentStatusChecker.process {
|
||||
ConfigurationItemView(
|
||||
title: .agentDetailsLocationTitle,
|
||||
value: process.bundleURL!.path(),
|
||||
action: .revealInFinder(process.bundleURL!.path()),
|
||||
)
|
||||
ConfigurationItemView(
|
||||
title: .agentDetailsSocketPathTitle,
|
||||
value: socketPath,
|
||||
action: .copy(socketPath),
|
||||
)
|
||||
ConfigurationItemView(
|
||||
title: .agentDetailsVersionTitle,
|
||||
value: Bundle(url: process.bundleURL!)!.infoDictionary!["CFBundleShortVersionString"] as! String
|
||||
)
|
||||
if let launchDate = process.launchDate {
|
||||
ConfigurationItemView(
|
||||
title: .agentDetailsRunningSinceTitle,
|
||||
value: launchDate.formatted()
|
||||
)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(.agentRunningNoticeDetailTitle)
|
||||
.font(.headline)
|
||||
.padding(.top)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(.agentRunningNoticeDetailDescription)
|
||||
HStack {
|
||||
Spacer()
|
||||
Menu(.agentDetailsRestartAgentButton) {
|
||||
Button(.agentDetailsDisableAgentButton) {
|
||||
Task {
|
||||
_ = await LaunchAgentController()
|
||||
.uninstall()
|
||||
agentStatusChecker.check()
|
||||
}
|
||||
}
|
||||
} primaryAction: {
|
||||
Task {
|
||||
let controller = LaunchAgentController()
|
||||
let installed = await controller.install()
|
||||
if !installed {
|
||||
_ = await controller.forceLaunch()
|
||||
}
|
||||
agentStatusChecker.check()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 400)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct AgentNotRunningView: View {
|
||||
|
||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||
@State var triedRestart = false
|
||||
@State var loading = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
} header: {
|
||||
Text(.agentNotRunningNoticeTitle)
|
||||
.font(.headline)
|
||||
.padding(.top)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(.agentNotRunningNoticeDetailDescription)
|
||||
HStack {
|
||||
if !triedRestart {
|
||||
Spacer()
|
||||
Button {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
Task {
|
||||
let controller = LaunchAgentController()
|
||||
let installed = await controller.install()
|
||||
if !installed {
|
||||
_ = await controller.forceLaunch()
|
||||
}
|
||||
agentStatusChecker.check()
|
||||
loading = false
|
||||
|
||||
if !agentStatusChecker.running {
|
||||
triedRestart = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if !loading {
|
||||
Text(.agentDetailsStartAgentButton)
|
||||
} else {
|
||||
HStack {
|
||||
Text(.agentDetailsStartAgentButtonStarting)
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
}
|
||||
.primaryButton()
|
||||
} else {
|
||||
Text(.agentDetailsCouldNotStartError)
|
||||
.bold()
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 400)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AgentStatusView()
|
||||
.environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: false))
|
||||
}
|
||||
#Preview {
|
||||
AgentStatusView()
|
||||
.environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: true, process: .current))
|
||||
}
|
||||
59
Sources/Secretive/Views/ConfigurationItemView.swift
Normal file
59
Sources/Secretive/Views/ConfigurationItemView.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConfigurationItemView<Content: View>: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ struct ContentView: View {
|
||||
toolbarItem(newItemView, id: "new")
|
||||
}
|
||||
.sheet(isPresented: $runningSetup) {
|
||||
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
|
||||
SetupView(setupComplete: $hasRunSetup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ extension ContentView {
|
||||
}
|
||||
|
||||
var needsSetup: Bool {
|
||||
(runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild
|
||||
runningSetup || !hasRunSetup
|
||||
}
|
||||
|
||||
/// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message
|
||||
@@ -66,7 +66,7 @@ extension ContentView {
|
||||
if needsSetup {
|
||||
setupNoticeView
|
||||
} else {
|
||||
runningNoticeView
|
||||
agentStatusToolbarView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ extension ContentView {
|
||||
.foregroundColor(.white)
|
||||
})
|
||||
.buttonStyle(ToolbarButtonStyle(color: color))
|
||||
.popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in
|
||||
.sheet(item: $selectedUpdate) { update in
|
||||
UpdateDetailView(update: update)
|
||||
}
|
||||
}
|
||||
@@ -103,18 +103,17 @@ extension ContentView {
|
||||
@ViewBuilder
|
||||
var newItemView: some View {
|
||||
if storeList.modifiableStore?.isAvailable ?? false {
|
||||
Button(action: {
|
||||
Button(.appMenuNewSecretButton, systemImage: "plus") {
|
||||
showingCreation = true
|
||||
}, label: {
|
||||
Image(systemName: "plus")
|
||||
})
|
||||
}
|
||||
.menuButton()
|
||||
.sheet(isPresented: $showingCreation) {
|
||||
if let modifiable = storeList.modifiableStore {
|
||||
CreateSecretView(store: modifiable, showing: $showingCreation)
|
||||
.onDisappear {
|
||||
guard let newest = modifiable.secrets.last else { return }
|
||||
activeSecret = newest
|
||||
CreateSecretView(store: modifiable) { created in
|
||||
if let created {
|
||||
activeSecret = created
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,43 +124,44 @@ extension ContentView {
|
||||
Button(action: {
|
||||
runningSetup = true
|
||||
}, label: {
|
||||
Group {
|
||||
if hasRunSetup && !agentStatusChecker.running {
|
||||
Text(.agentNotRunningNoticeTitle)
|
||||
} else {
|
||||
Text(.agentSetupNoticeTitle)
|
||||
}
|
||||
if !hasRunSetup {
|
||||
Text(.agentSetupNoticeTitle)
|
||||
.font(.headline)
|
||||
}
|
||||
.font(.headline)
|
||||
|
||||
})
|
||||
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var runningNoticeView: some View {
|
||||
var agentStatusToolbarView: some View {
|
||||
Button(action: {
|
||||
showingAgentInfo = true
|
||||
}, label: {
|
||||
HStack {
|
||||
Text(.agentRunningNoticeTitle)
|
||||
.font(.headline)
|
||||
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
|
||||
Circle()
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(Color.green)
|
||||
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: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
|
||||
.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) {
|
||||
VStack {
|
||||
Text(.agentRunningNoticeDetailTitle)
|
||||
.font(.title)
|
||||
.padding(5)
|
||||
Text(.agentRunningNoticeDetailDescription)
|
||||
.frame(width: 300)
|
||||
}
|
||||
.padding()
|
||||
AgentStatusView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,6 @@ extension ContentView {
|
||||
}
|
||||
|
||||
var attachmentAnchor: PopoverAttachmentAnchor {
|
||||
// Ideally .point(.bottom), but broken on Sonoma (FB12726503)
|
||||
.rect(.bounds)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,10 +76,10 @@ struct CopyableView: View {
|
||||
switch interactionState {
|
||||
case .hovering:
|
||||
Image(systemName: "document.on.document")
|
||||
.accessibilityLabel(String(localized: "copyable_click_to_copy_button"))
|
||||
.accessibilityLabel(String(localized: .copyableClickToCopyButton))
|
||||
case .clicking:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.accessibilityLabel(String(localized: "copyable_copied"))
|
||||
.accessibilityLabel(String(localized: .copyableCopied))
|
||||
case .normal, .dragging:
|
||||
EmptyView()
|
||||
}
|
||||
@@ -168,9 +168,9 @@ fileprivate struct BackgroundViewModifier: ViewModifier {
|
||||
struct CopyableView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "figure.wave"), text: "Hello world.")
|
||||
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "figure.wave"), text: "Hello world.")
|
||||
.padding()
|
||||
CopyableView(title: "secret_detail_sha256_fingerprint_label", image: Image(systemName: "figure.wave"), text: "Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. ")
|
||||
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "figure.wave"), text: "Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. ")
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import SecretKit
|
||||
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
|
||||
@State var store: StoreType
|
||||
@Binding var showing: Bool
|
||||
@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 {
|
||||
@@ -94,16 +96,24 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
} footer: {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
|
||||
.toggleStyle(.button)
|
||||
Spacer()
|
||||
Button(.createSecretCancelButton, role: .cancel) {
|
||||
showing = false
|
||||
dismiss()
|
||||
}
|
||||
Button(.createSecretCreateButton, action: save)
|
||||
.primary()
|
||||
.keyboardShortcut(.return)
|
||||
.primaryButton()
|
||||
.disabled(name.isEmpty)
|
||||
}
|
||||
.padding()
|
||||
@@ -117,20 +127,25 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
func save() {
|
||||
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
|
||||
Task {
|
||||
try! await store.create(
|
||||
name: name,
|
||||
attributes: .init(
|
||||
keyType: keyType!,
|
||||
authentication: authenticationRequirement,
|
||||
publicKeyAttribution: attribution
|
||||
do {
|
||||
let new = try await store.create(
|
||||
name: name,
|
||||
attributes: .init(
|
||||
keyType: keyType!,
|
||||
authentication: authenticationRequirement,
|
||||
publicKeyAttribution: attribution
|
||||
)
|
||||
)
|
||||
)
|
||||
showing = false
|
||||
createdSecret(AnySecret(new))
|
||||
dismiss()
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
|
||||
CreateSecretView(store: Preview.StoreModifiable()) { _ in }
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ struct DeleteSecretConfirmationModifier: ViewModifier {
|
||||
TextField(secret.name, text: $confirmedSecretName)
|
||||
if let errorText {
|
||||
Text(verbatim: errorText)
|
||||
.foregroundStyle(.red)
|
||||
.font(.callout)
|
||||
.errorStyle()
|
||||
}
|
||||
Button(.deleteConfirmationDeleteButton, action: delete)
|
||||
.disabled(confirmedSecretName != secret.name)
|
||||
|
||||
@@ -30,21 +30,22 @@ struct EditSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let errorText {
|
||||
Text(verbatim: errorText)
|
||||
.foregroundStyle(.red)
|
||||
.font(.callout)
|
||||
} footer: {
|
||||
if let errorText {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button(.editSaveButton, action: rename)
|
||||
.disabled(name.isEmpty)
|
||||
.keyboardShortcut(.return)
|
||||
Button(.editCancelButton) {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button(.editSaveButton, action: rename)
|
||||
.disabled(name.isEmpty)
|
||||
.keyboardShortcut(.return)
|
||||
.primaryButton()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
19
Sources/Secretive/Views/ErrorStyle.swift
Normal file
19
Sources/Secretive/Views/ErrorStyle.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ErrorStyleModifier: ViewModifier {
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.foregroundStyle(.red)
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func errorStyle() -> some View {
|
||||
modifier(ErrorStyleModifier())
|
||||
}
|
||||
|
||||
}
|
||||
350
Sources/Secretive/Views/IntegrationsView.swift
Normal file
350
Sources/Secretive/Views/IntegrationsView.swift
Normal file
@@ -0,0 +1,350 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IntegrationsView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedInstruction: ConfigurationFileInstructions?
|
||||
private let instructions = Instructions()
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selectedInstruction) {
|
||||
ForEach(instructions.instructions) { group in
|
||||
Section(group.name) {
|
||||
ForEach(group.instructions) { instruction in
|
||||
Text(instruction.tool)
|
||||
.padding(.vertical, 8)
|
||||
.tag(instruction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
IntegrationsDetailView(selectedInstruction: $selectedInstruction)
|
||||
.fauxToolbar {
|
||||
Button(.setupDoneButton) {
|
||||
dismiss()
|
||||
}
|
||||
.normalButton()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedInstruction = instructions.gettingStarted
|
||||
}
|
||||
.frame(minHeight: 500)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
func fauxToolbar<Content: View>(content: () -> Content) -> some View {
|
||||
modifier(FauxToolbarModifier(toolbarContent: content()))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FauxToolbarModifier<ToolbarContent: View>: ViewModifier {
|
||||
|
||||
var toolbarContent: ToolbarContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
content
|
||||
Divider()
|
||||
HStack {
|
||||
Spacer()
|
||||
toolbarContent
|
||||
.padding(.top, 8)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct IntegrationsDetailView: View {
|
||||
|
||||
@Binding private var selectedInstruction: ConfigurationFileInstructions?
|
||||
private let instructions = Instructions()
|
||||
|
||||
init(selectedInstruction: Binding<ConfigurationFileInstructions?>) {
|
||||
_selectedInstruction = selectedInstruction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let selectedInstruction {
|
||||
switch selectedInstruction.id {
|
||||
case .gettingStarted:
|
||||
Form {
|
||||
Section(.integrationsGettingStartedTitle) {
|
||||
Text(.integrationsGettingStartedTitleDescription)
|
||||
}
|
||||
Section {
|
||||
Group {
|
||||
Text(.integrationsGettingStartedSuggestionSsh)
|
||||
.onTapGesture {
|
||||
self.selectedInstruction = instructions.ssh
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(.integrationsGettingStartedSuggestionShell)
|
||||
Text(.integrationsGettingStartedSuggestionShellDefault(shellName: instructions.defaultShell.tool))
|
||||
.font(.caption2)
|
||||
}
|
||||
.onTapGesture {
|
||||
self.selectedInstruction = instructions.defaultShell
|
||||
}
|
||||
Text(.integrationsGettingStartedSuggestionGit)
|
||||
.onTapGesture {
|
||||
self.selectedInstruction = instructions.git
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.link)
|
||||
|
||||
} header: {
|
||||
Text(.integrationsGettingStartedWhatShouldIConfigureTitle)
|
||||
}
|
||||
footer: {
|
||||
Text(.integrationsGettingStartedMultipleConfig)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
case .tool:
|
||||
Form {
|
||||
ForEach(selectedInstruction.steps) { stepGroup in
|
||||
Section {
|
||||
ConfigurationItemView(title: .integrationsPathTitle, value: stepGroup.path, action: .revealInFinder(stepGroup.path))
|
||||
ForEach(stepGroup.steps, id: \.self) { step in
|
||||
ConfigurationItemView(title: .integrationsAddThisTitle, action: .copy(step)) {
|
||||
HStack {
|
||||
Text(step)
|
||||
.padding(8)
|
||||
.font(.system(.subheadline, design: .monospaced))
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.black.opacity(0.05))
|
||||
.stroke(.separator, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
if let note = stepGroup.note {
|
||||
Text(note)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let url = selectedInstruction.website {
|
||||
Section {
|
||||
Link(destination: url) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(.integrationsWebLink)
|
||||
.font(.headline)
|
||||
Text(url.absoluteString)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
case .otherShell:
|
||||
Form {
|
||||
Section {
|
||||
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/shells")!)
|
||||
} header: {
|
||||
Text(.integrationsCommunityShellListDescription)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
case .otherApp:
|
||||
Form {
|
||||
Section {
|
||||
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/apps")!)
|
||||
} header: {
|
||||
Text(.integrationsCommunityAppsListDescription)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private struct Instructions {
|
||||
|
||||
private let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
|
||||
|
||||
|
||||
var defaultShell: ConfigurationFileInstructions {
|
||||
zsh
|
||||
}
|
||||
|
||||
var gettingStarted: ConfigurationFileInstructions = ConfigurationFileInstructions(.integrationsGettingStartedRowTitle, id: .gettingStarted)
|
||||
|
||||
var ssh: ConfigurationFileInstructions {
|
||||
ConfigurationFileInstructions(
|
||||
tool: "SSH",
|
||||
configPath: "~/.ssh/config",
|
||||
configText: "Host *\n\tIdentityAgent \(socketPath)",
|
||||
website: URL(string: "https://man.openbsd.org/ssh_config.5")!,
|
||||
note: "You can tell SSH to use a specific key for a given host. See the web documentation for more details.",
|
||||
)
|
||||
}
|
||||
|
||||
var git: ConfigurationFileInstructions {
|
||||
ConfigurationFileInstructions(
|
||||
tool: "Git Signing",
|
||||
steps: [
|
||||
.init(path: "~/.gitconfig", steps: [
|
||||
"""
|
||||
[user]
|
||||
signingkey = YOUR_PUBLIC_KEY_PATH
|
||||
[commit]
|
||||
gpgsign = true
|
||||
[gpg]
|
||||
format = ssh
|
||||
[gpg "ssh"]
|
||||
allowedSignersFile = ~/.gitallowedsigners
|
||||
"""
|
||||
],
|
||||
note: "If any section (like [user]) already exists, just add the entries in the existing section."
|
||||
|
||||
),
|
||||
.init(
|
||||
path: "~/.gitallowedsigners",
|
||||
steps: [
|
||||
"YOUR_PUBLIC_KEY"
|
||||
],
|
||||
note: "~/.gitallowedsigners probably does not exist. You'll need to create it."
|
||||
),
|
||||
],
|
||||
website: URL(string: "https://git-scm.com/docs/git-config")!,
|
||||
)
|
||||
}
|
||||
|
||||
var zsh: ConfigurationFileInstructions {
|
||||
ConfigurationFileInstructions(
|
||||
tool: "zsh",
|
||||
configPath: "~/.zshrc",
|
||||
configText: "export SSH_AUTH_SOCK=\(socketPath)"
|
||||
)
|
||||
}
|
||||
|
||||
var instructions: [ConfigurationGroup] {
|
||||
[
|
||||
ConfigurationGroup(name: .integrationsGettingStartedSectionTitle, instructions: [
|
||||
gettingStarted
|
||||
]),
|
||||
ConfigurationGroup(
|
||||
name: .integrationsSystemSectionTitle,
|
||||
instructions: [
|
||||
ssh,
|
||||
git,
|
||||
]
|
||||
),
|
||||
ConfigurationGroup(name: .integrationsShellSectionTitle, instructions: [
|
||||
zsh,
|
||||
ConfigurationFileInstructions(
|
||||
tool: "bash",
|
||||
configPath: "~/.bashrc",
|
||||
configText: "export SSH_AUTH_SOCK=\(socketPath)"
|
||||
),
|
||||
ConfigurationFileInstructions(
|
||||
tool: "fish",
|
||||
configPath: "~/.config/fish/config.fish",
|
||||
configText: "set -x SSH_AUTH_SOCK \(socketPath)"
|
||||
),
|
||||
ConfigurationFileInstructions(.integrationsOtherShellRowTitle, id: .otherShell),
|
||||
]),
|
||||
ConfigurationGroup(name: .integrationsOtherSectionTitle, instructions: [
|
||||
ConfigurationFileInstructions(.integrationsAppsRowTitle, id: .otherApp),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ConfigurationGroup: Identifiable {
|
||||
let id = UUID()
|
||||
var name: LocalizedStringResource
|
||||
var instructions: [ConfigurationFileInstructions] = []
|
||||
}
|
||||
|
||||
struct ConfigurationFileInstructions: Hashable, Identifiable {
|
||||
|
||||
struct StepGroup: Hashable, Identifiable {
|
||||
let path: String
|
||||
let steps: [String]
|
||||
let note: String?
|
||||
var id: String { path }
|
||||
|
||||
init(path: String, steps: [String], note: String? = nil) {
|
||||
self.path = path
|
||||
self.steps = steps
|
||||
self.note = note
|
||||
}
|
||||
}
|
||||
|
||||
var id: ID
|
||||
var tool: String
|
||||
var steps: [StepGroup]
|
||||
var website: URL?
|
||||
|
||||
init(tool: String, configPath: String, configText: String, website: URL? = nil, note: String? = nil) {
|
||||
self.id = .tool(tool)
|
||||
self.tool = tool
|
||||
self.steps = [StepGroup(path: configPath, steps: [configText], note: note)]
|
||||
self.website = website
|
||||
}
|
||||
|
||||
init(tool: String, steps: [StepGroup], website: URL? = nil) {
|
||||
self.id = .tool(tool)
|
||||
self.tool = tool
|
||||
self.steps = steps
|
||||
self.website = website
|
||||
}
|
||||
|
||||
init(_ name: LocalizedStringResource, id: ID) {
|
||||
self.id = id
|
||||
tool = String(localized: name)
|
||||
self.steps = []
|
||||
}
|
||||
|
||||
enum ID: Identifiable, Hashable {
|
||||
case gettingStarted
|
||||
case tool(String)
|
||||
case otherShell
|
||||
case otherApp
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .gettingStarted:
|
||||
"getting_started"
|
||||
case .tool(let name):
|
||||
name
|
||||
case .otherShell:
|
||||
"other_shell"
|
||||
case .otherApp:
|
||||
"other_app"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
IntegrationsView()
|
||||
.frame(height: 500)
|
||||
}
|
||||
@@ -6,8 +6,8 @@ struct SecretDetailView<SecretType: Secret>: View {
|
||||
let secret: SecretType
|
||||
|
||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID))
|
||||
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Form {
|
||||
@@ -37,12 +37,14 @@ struct SecretDetailView<SecretType: Secret>: View {
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension URL {
|
||||
|
||||
struct SecretDetailView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SecretDetailView(secret: Preview.Store(numberOfRandomSecrets: 1).secrets[0])
|
||||
static var agentHomeURL: URL {
|
||||
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
#Preview {
|
||||
SecretDetailView(secret: Preview.Secret(name: "Demonstration Secret"))
|
||||
}
|
||||
|
||||
@@ -2,229 +2,174 @@ import SwiftUI
|
||||
|
||||
struct SetupView: View {
|
||||
|
||||
@State var stepIndex = 0
|
||||
@Binding var visible: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var setupComplete: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
VStack {
|
||||
StepView(numberOfSteps: 3, currentStep: stepIndex, width: proxy.size.width)
|
||||
GeometryReader { _ in
|
||||
HStack(spacing: 0) {
|
||||
SecretAgentSetupView(buttonAction: advance)
|
||||
.frame(width: proxy.size.width)
|
||||
SSHAgentSetupView(buttonAction: advance)
|
||||
.frame(width: proxy.size.width)
|
||||
UpdaterExplainerView {
|
||||
visible = false
|
||||
setupComplete = true
|
||||
}
|
||||
.frame(width: proxy.size.width)
|
||||
}
|
||||
.offset(x: -proxy.size.width * Double(stepIndex), y: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 500, idealWidth: 500, minHeight: 500, idealHeight: 500)
|
||||
}
|
||||
@State var showingIntegrations = false
|
||||
@State var buttonWidth: CGFloat?
|
||||
|
||||
|
||||
func advance() {
|
||||
withAnimation(.spring()) {
|
||||
stepIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct StepView: View {
|
||||
|
||||
let numberOfSteps: Int
|
||||
let currentStep: Int
|
||||
|
||||
// Ideally we'd have a geometry reader inside this view doing this for us, but that crashes on 11.0b7
|
||||
let width: Double
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.foregroundColor(.blue)
|
||||
.frame(height: 5)
|
||||
Rectangle()
|
||||
.foregroundColor(.green)
|
||||
.frame(width: max(0, ((width - (Constants.padding * 2)) / Double(numberOfSteps - 1)) * Double(currentStep) - (Constants.circleWidth / 2)), height: 5)
|
||||
HStack {
|
||||
ForEach(Array(0..<numberOfSteps), id: \.self) { index in
|
||||
ZStack {
|
||||
if currentStep > index {
|
||||
Circle()
|
||||
.foregroundColor(.green)
|
||||
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
|
||||
Text(.setupStepCompleteSymbol)
|
||||
.foregroundColor(.white)
|
||||
.bold()
|
||||
} else {
|
||||
Circle()
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
|
||||
if currentStep == index {
|
||||
Circle()
|
||||
.strokeBorder(Color.white, lineWidth: 3)
|
||||
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
|
||||
}
|
||||
Text(String(describing: index + 1))
|
||||
.foregroundColor(.white)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
if index < numberOfSteps - 1 {
|
||||
Spacer(minLength: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.padding(Constants.padding)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StepView {
|
||||
|
||||
enum Constants {
|
||||
|
||||
static let padding: Double = 15
|
||||
static let circleWidth: Double = 30
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SetupStepView<Content> : View where Content : View {
|
||||
|
||||
let title: LocalizedStringResource
|
||||
let image: Image
|
||||
let bodyText: LocalizedStringResource
|
||||
let buttonTitle: LocalizedStringResource
|
||||
let buttonAction: () -> Void
|
||||
let content: Content
|
||||
|
||||
init(title: LocalizedStringResource, image: Image, bodyText: LocalizedStringResource, buttonTitle: LocalizedStringResource, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.image = image
|
||||
self.bodyText = bodyText
|
||||
self.buttonTitle = buttonTitle
|
||||
self.buttonAction = buttonAction
|
||||
self.content = content()
|
||||
@State var installed = false
|
||||
@State var updates = false
|
||||
@State var integrations = false
|
||||
var allDone: Bool {
|
||||
installed && updates && integrations
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(title)
|
||||
.font(.title)
|
||||
Spacer()
|
||||
image
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
StepView(
|
||||
title: .setupAgentTitle,
|
||||
description: .setupAgentDescription,
|
||||
systemImage: "lock.laptopcomputer",
|
||||
) {
|
||||
setupButton(
|
||||
.setupAgentInstallButton,
|
||||
complete: installed,
|
||||
width: buttonWidth
|
||||
) {
|
||||
installed = true
|
||||
Task {
|
||||
await LaunchAgentController().install()
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
StepView(
|
||||
title: .setupUpdatesTitle,
|
||||
description: .setupUpdatesDescription,
|
||||
systemImage: "network.badge.shield.half.filled",
|
||||
) {
|
||||
setupButton(
|
||||
.setupUpdatesOkButton,
|
||||
complete: updates,
|
||||
width: buttonWidth
|
||||
) {
|
||||
updates = true
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
StepView(
|
||||
title: .setupIntegrationsTitle,
|
||||
description: .setupIntegrationsDescription,
|
||||
systemImage: "firewall",
|
||||
) {
|
||||
setupButton(
|
||||
.setupIntegrationsButton,
|
||||
complete: integrations,
|
||||
width: buttonWidth
|
||||
) {
|
||||
showingIntegrations = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(setupButton.WidthKey.self) { width in
|
||||
buttonWidth = width
|
||||
}
|
||||
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
.frame(minWidth: 700, maxWidth: .infinity)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(.setupDoneButton) {
|
||||
setupComplete = true
|
||||
dismiss()
|
||||
}
|
||||
.disabled(!allDone)
|
||||
.primaryButton()
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
.padding()
|
||||
.sheet(isPresented: $showingIntegrations, onDismiss: {
|
||||
integrations = true
|
||||
}, content: {
|
||||
IntegrationsView()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct setupButton: View {
|
||||
|
||||
struct WidthKey: @MainActor PreferenceKey {
|
||||
@MainActor static var defaultValue: CGFloat? = nil
|
||||
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
||||
if let next = nextValue(), next > (value ?? -1) {
|
||||
value = next
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let label: LocalizedStringResource
|
||||
let complete: Bool
|
||||
let action: () -> Void
|
||||
let width: CGFloat?
|
||||
@State var currentWidth: CGFloat?
|
||||
|
||||
init(_ label: LocalizedStringResource, complete: Bool, width: CGFloat? = nil, action: @escaping () -> Void) {
|
||||
self.label = label
|
||||
self.complete = complete
|
||||
self.action = action
|
||||
self.width = width
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
if complete {
|
||||
Text(.setupStepCompleteButton)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
} else {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
.frame(width: width)
|
||||
.padding(.vertical, 2)
|
||||
.onGeometryChange(for: CGFloat.self) { proxy in
|
||||
proxy.size.width
|
||||
} action: { newValue in
|
||||
currentWidth = newValue
|
||||
}
|
||||
}
|
||||
.preference(key: WidthKey.self, value: currentWidth)
|
||||
.primaryButton()
|
||||
.disabled(complete)
|
||||
.tint(complete ? .green : nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct StepView<Content: View>: View {
|
||||
|
||||
let title: LocalizedStringResource
|
||||
let icon: Image
|
||||
let description: LocalizedStringResource
|
||||
let actions: Content
|
||||
|
||||
init(title: LocalizedStringResource, description: LocalizedStringResource, systemImage: String, actions: () -> Content) {
|
||||
self.title = title
|
||||
self.icon = Image(systemName: systemImage)
|
||||
self.description = description
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
icon
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 64)
|
||||
Spacer()
|
||||
Text(bodyText)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer()
|
||||
content
|
||||
Spacer()
|
||||
Button(buttonTitle) {
|
||||
buttonAction()
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.bold()
|
||||
Text(description)
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SecretAgentSetupView: View {
|
||||
|
||||
let buttonAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
SetupStepView(title: .setupAgentTitle,
|
||||
image: Image(nsImage: NSApplication.shared.applicationIconImage),
|
||||
bodyText: .setupAgentDescription,
|
||||
buttonTitle: .setupAgentInstallButton,
|
||||
buttonAction: install) {
|
||||
Text(.setupAgentActivityMonitorDescription)
|
||||
.multilineTextAlignment(.center)
|
||||
Spacer()
|
||||
actions
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
func install() {
|
||||
Task {
|
||||
await LaunchAgentController().install()
|
||||
buttonAction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SSHAgentSetupView: View {
|
||||
|
||||
let buttonAction: () -> Void
|
||||
|
||||
private static let controller = ShellConfigurationController()
|
||||
@State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first!
|
||||
|
||||
var body: some View {
|
||||
SetupStepView(title: .setupSshTitle,
|
||||
image: Image(systemName: "terminal"),
|
||||
bodyText: .setupSshDescription,
|
||||
buttonTitle: .setupSshAddedManuallyButton,
|
||||
buttonAction: buttonAction) {
|
||||
Link(.setupThirdPartyFaqLink, destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!)
|
||||
Picker(selection: $selectedShellInstruction, label: EmptyView()) {
|
||||
ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in
|
||||
Text(instruction.shell)
|
||||
.tag(instruction)
|
||||
.padding()
|
||||
}
|
||||
}.pickerStyle(SegmentedPickerStyle())
|
||||
CopyableView(title: .setupSshAddToConfigButton(configPath: selectedShellInstruction.shellConfigPath), image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
|
||||
Button(.setupSshAddForMeButton) {
|
||||
let controller = ShellConfigurationController()
|
||||
if controller.addToShell(shellInstructions: selectedShellInstruction) {
|
||||
buttonAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Delegate: NSObject, NSOpenSavePanelDelegate {
|
||||
|
||||
private let name: String
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
func panel(_ sender: Any, shouldEnable url: URL) -> Bool {
|
||||
return url.lastPathComponent == name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct UpdaterExplainerView: View {
|
||||
|
||||
let buttonAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
SetupStepView(title: .setupUpdatesTitle,
|
||||
image: Image(systemName: "dot.radiowaves.left.and.right"),
|
||||
bodyText: .setupUpdatesDescription,
|
||||
buttonTitle: .setupUpdatesOk,
|
||||
buttonAction: buttonAction) {
|
||||
Link(.setupUpdatesReadmore, destination: SetupView.Constants.updaterFAQURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
extension SetupView {
|
||||
@@ -235,63 +180,6 @@ extension SetupView {
|
||||
|
||||
}
|
||||
|
||||
struct ShellConfigInstruction: Identifiable, Hashable {
|
||||
|
||||
var shell: String
|
||||
var shellConfigDirectory: String
|
||||
var shellConfigFilename: String
|
||||
var text: String
|
||||
|
||||
var id: String {
|
||||
shell
|
||||
}
|
||||
|
||||
var shellConfigPath: String {
|
||||
return (shellConfigDirectory as NSString).appendingPathComponent(shellConfigFilename)
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SetupView(setupComplete: .constant(false))
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
struct SetupView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
SetupView(visible: .constant(true), setupComplete: .constant(false))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SecretAgentSetupView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
SecretAgentSetupView(buttonAction: {})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct SSHAgentSetupView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
SSHAgentSetupView(buttonAction: {})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct UpdaterExplainerView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
UpdaterExplainerView(buttonAction: {})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -12,7 +12,7 @@ struct UpdateDetailView: View {
|
||||
Text(.updateVersionName(updateName: update.name)).font(.title)
|
||||
GroupBox(label: Text(.updateReleaseNotesTitle)) {
|
||||
ScrollView {
|
||||
attributedBody
|
||||
Text(attributedBody)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
@@ -35,29 +35,62 @@ struct UpdateDetailView: View {
|
||||
.frame(maxWidth: 500)
|
||||
}
|
||||
|
||||
var attributedBody: Text {
|
||||
var text = Text(verbatim: "")
|
||||
for line in update.body.split(whereSeparator: \.isNewline) {
|
||||
let attributed: Text
|
||||
let split = line.split(separator: " ")
|
||||
let unprefixed = split.dropFirst().joined(separator: " ")
|
||||
if let prefix = split.first {
|
||||
switch prefix {
|
||||
case "#":
|
||||
attributed = Text(unprefixed).font(.title) + Text(verbatim: "\n")
|
||||
case "##":
|
||||
attributed = Text(unprefixed).font(.title2) + Text(verbatim: "\n")
|
||||
case "###":
|
||||
attributed = Text(unprefixed).font(.title3) + Text(verbatim: "\n")
|
||||
var attributedBody: AttributedString {
|
||||
do {
|
||||
var text = try AttributedString(
|
||||
markdown: update.body,
|
||||
options: .init(
|
||||
allowsExtendedAttributes: true,
|
||||
interpretedSyntax: .full,
|
||||
),
|
||||
baseURL: URL(string: "https://github.com/maxgoedjen/secretive")!
|
||||
)
|
||||
.transformingAttributes(AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self) { key in
|
||||
let font: Font? = switch key.value?.components.first?.kind {
|
||||
case .header(level: 1):
|
||||
Font.title
|
||||
case .header(level: 2):
|
||||
Font.title2
|
||||
case .header(level: 3):
|
||||
Font.title3
|
||||
default:
|
||||
attributed = Text(line) + Text(verbatim: "\n\n")
|
||||
nil
|
||||
}
|
||||
if let font {
|
||||
key.replace(with: AttributeScopes.SwiftUIAttributes.FontAttribute.self, value: font)
|
||||
}
|
||||
} else {
|
||||
attributed = Text(line) + Text(verbatim: "\n\n")
|
||||
}
|
||||
text = text + attributed
|
||||
let lineBreak = AttributedString("\n\n")
|
||||
for run in text.runs.reversed() {
|
||||
text.insert(lineBreak, at: run.range.lowerBound)
|
||||
}
|
||||
return text
|
||||
} catch {
|
||||
var text = AttributedString()
|
||||
for line in update.body.split(whereSeparator: \.isNewline) {
|
||||
let attributed: AttributedString
|
||||
let split = line.split(separator: " ")
|
||||
let unprefixed = split.dropFirst().joined(separator: " ")
|
||||
if let prefix = split.first {
|
||||
var container = AttributeContainer()
|
||||
switch prefix {
|
||||
case "#":
|
||||
container.font = .title
|
||||
case "##":
|
||||
container.font = .title2
|
||||
case "###":
|
||||
container.font = .title3
|
||||
default:
|
||||
continue
|
||||
}
|
||||
attributed = AttributedString(unprefixed, attributes: container)
|
||||
} else {
|
||||
attributed = AttributedString(line + "\n\n")
|
||||
}
|
||||
text = text + attributed
|
||||
}
|
||||
return text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user