New onboarding flow.

This commit is contained in:
Max Goedjen 2020-09-20 00:12:20 -07:00
parent d1a2ae79bf
commit e2f519987f
No known key found for this signature in database
GPG Key ID: E58C21DD77B9B8E8
5 changed files with 369 additions and 293 deletions

View File

@ -29,6 +29,8 @@
50617DCE23FCECFA0099B055 /* SecureEnclaveSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617DCD23FCECFA0099B055 /* SecureEnclaveSecret.swift */; };
50617DD023FCED2C0099B055 /* SecureEnclave.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617DCF23FCED2C0099B055 /* SecureEnclave.swift */; };
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617DD123FCEFA90099B055 /* PreviewStore.swift */; };
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C12516F303004B5A36 /* SetupView.swift */; };
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; };
506772C72424784600034DED /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 506772C62424784600034DED /* Credits.rtf */; };
506772C92425BB8500034DED /* NoStoresView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506772C82425BB8500034DED /* NoStoresView.swift */; };
506772FF2426F3F400034DED /* Brief.h in Headers */ = {isa = PBXBuildFile; fileRef = 506772FD2426F3F400034DED /* Brief.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -78,7 +80,6 @@
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
50C385A3240789E600AF2719 /* OpenSSHReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A2240789E600AF2719 /* OpenSSHReader.swift */; };
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
50C385A9240B636500AF2719 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A8240B636500AF2719 /* SetupView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -234,6 +235,8 @@
50617DCD23FCECFA0099B055 /* SecureEnclaveSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureEnclaveSecret.swift; sourceTree = "<group>"; };
50617DCF23FCED2C0099B055 /* SecureEnclave.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureEnclave.swift; sourceTree = "<group>"; };
50617DD123FCEFA90099B055 /* PreviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewStore.swift; sourceTree = "<group>"; };
5066A6C12516F303004B5A36 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
5066A6C72516FE6E004B5A36 /* CopyableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableView.swift; sourceTree = "<group>"; };
506772C62424784600034DED /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = "<group>"; };
506772C82425BB8500034DED /* NoStoresView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoStoresView.swift; sourceTree = "<group>"; };
506772FB2426F3F400034DED /* Brief.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Brief.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -283,7 +286,6 @@
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStoreView.swift; sourceTree = "<group>"; };
50C385A2240789E600AF2719 /* OpenSSHReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OpenSSHReader.swift; path = SecretKit/Common/OpenSSH/OpenSSHReader.swift; sourceTree = SOURCE_ROOT; };
50C385A42407A76D00AF2719 /* SecretDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretDetailView.swift; sourceTree = "<group>"; };
50C385A8240B636500AF2719 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -510,7 +512,8 @@
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */,
506772C82425BB8500034DED /* NoStoresView.swift */,
50153E1F250AFCB200525160 /* UpdateView.swift */,
50C385A8240B636500AF2719 /* SetupView.swift */,
5066A6C12516F303004B5A36 /* SetupView.swift */,
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -922,7 +925,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
50C385A9240B636500AF2719 /* SetupView.swift in Sources */,
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
@ -932,6 +935,7 @@
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */,
50153E20250AFCB200525160 /* UpdateView.swift in Sources */,
50571E0524393D1500F76F6C /* LaunchAgentController.swift in Sources */,
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */,
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */,
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */,
50617D8323FCE48E0099B055 /* App.swift in Sources */,

View File

@ -53,18 +53,19 @@ extension ContentView {
color = .orange
}
return ToolbarItem {
AnyView(Button(action: {
selectedUpdate = update
}, label: {
Text(text)
.font(.headline)
.foregroundColor(.white)
})
.background(color)
.cornerRadius(5)
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
UpdateDetailView(update: update)
}
AnyView(
Button(action: {
selectedUpdate = update
}, label: {
Text(text)
.font(.headline)
.foregroundColor(.white)
})
.background(color)
.cornerRadius(5)
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
UpdateDetailView(update: update)
}
)
}
}
@ -74,11 +75,13 @@ extension ContentView {
return ToolbarItem { AnyView(Spacer()) }
}
return ToolbarItem {
AnyView(Button(action: {
showingCreation = true
}, label: {
Image(systemName: "plus")
}))
AnyView(
Button(action: {
showingCreation = true
}, label: {
Image(systemName: "plus")
})
)
}
}
@ -104,10 +107,11 @@ extension ContentView {
.background(Color.orange)
.cornerRadius(5)
.popover(isPresented: $runningSetup, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
SetupView { completed in
runningSetup = false
hasRunSetup = completed
}
// SetupView { completed in
// runningSetup = false
// hasRunSetup = completed
// }
SetupView(visible: $runningSetup)
}
)
}

View File

@ -0,0 +1,131 @@
import SwiftUI
struct CopyableView: View {
var title: String
var image: Image
var text: String
@State private var interactionState: InteractionState = .normal
var body: some View {
VStack(alignment: .leading) {
HStack {
image
.renderingMode(.template)
.imageScale(.large)
.foregroundColor(primaryTextColor)
Text(title)
.font(.headline)
.foregroundColor(primaryTextColor)
Spacer()
if interactionState != .normal {
Text(hoverText)
.bold()
.textCase(.uppercase)
.foregroundColor(secondaryTextColor)
.transition(.opacity)
}
}
.padding(EdgeInsets(top: 20, leading: 20, bottom: 10, trailing: 20))
Divider()
Text(text)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(primaryTextColor)
.padding(EdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20))
.multilineTextAlignment(.leading)
.font(.system(.body, design: .monospaced))
}
.background(backgroundColor)
.frame(minWidth: 150, maxWidth: .infinity)
.cornerRadius(10)
.onHover { hovering in
withAnimation {
interactionState = hovering ? .hovering : .normal
}
}
.onDrag {
NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: kUTTypeUTF8PlainText as String)
}
.onTapGesture {
copy()
withAnimation {
interactionState = .clicking
}
}
.gesture(
TapGesture()
.onEnded {
withAnimation {
interactionState = .normal
}
}
)
}
var hoverText: String {
switch interactionState {
case .hovering:
return "Click to Copy"
case .clicking:
return "Copied"
case .normal:
fatalError()
}
}
var backgroundColor: Color {
let color: NSColor
switch interactionState {
case .normal:
color = .windowBackgroundColor
case .hovering:
color = .unemphasizedSelectedContentBackgroundColor
case .clicking:
color = .selectedContentBackgroundColor
}
return Color(color)
}
var primaryTextColor: Color {
let color: NSColor
switch interactionState {
case .normal, .hovering:
color = .textColor
case .clicking:
color = .white
}
return Color(color)
}
var secondaryTextColor: Color {
let color: NSColor
switch interactionState {
case .normal, .hovering:
color = .secondaryLabelColor
case .clicking:
color = .white
}
return Color(color)
}
func copy() {
NSPasteboard.general.declareTypes([.string], owner: nil)
NSPasteboard.general.setString(text, forType: .string)
}
private enum InteractionState {
case normal, hovering, clicking
}
}
struct CopyableView_Previews: PreviewProvider {
static var previews: some View {
Group {
CopyableView(title: "Title", image: Image(systemName: "figure.wave"), text: "Hello world.")
CopyableView(title: "Title", 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. ")
}
}
}

View File

@ -18,8 +18,7 @@ struct SecretDetailView<SecretType: Secret>: View {
}
}
.padding()
.frame(minHeight: 150, maxHeight: .infinity)
.frame(minHeight: 200, maxHeight: .infinity)
}
var keyString: String {
@ -39,122 +38,3 @@ struct SecretDetailView_Previews: PreviewProvider {
}
}
struct CopyableView: View {
var title: String
var image: Image
var text: String
@State private var interactionState: InteractionState = .normal
var body: some View {
VStack(alignment: .leading) {
HStack {
image
.renderingMode(.template)
.imageScale(.large)
.foregroundColor(primaryTextColor)
Text(title)
.font(.headline)
.foregroundColor(primaryTextColor)
Spacer()
if interactionState != .normal {
Text(hoverText)
.bold()
.textCase(.uppercase)
.foregroundColor(secondaryTextColor)
.transition(.opacity)
}
}
.padding(EdgeInsets(top: 20, leading: 20, bottom: 10, trailing: 20))
Divider()
Text(text)
.foregroundColor(primaryTextColor)
.padding(EdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20))
.multilineTextAlignment(.leading)
.font(.system(.body, design: .monospaced))
}
.background(backgroundColor)
.frame(minWidth: 150, maxWidth: .infinity)
.cornerRadius(10)
.onHover { hovering in
withAnimation {
interactionState = hovering ? .hovering : .normal
}
}
.onDrag {
NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: kUTTypeUTF8PlainText as String)
}
.onTapGesture {
copy()
withAnimation {
interactionState = .clicking
}
}
.gesture(
TapGesture()
.onEnded {
withAnimation {
interactionState = .normal
}
}
)
}
var hoverText: String {
switch interactionState {
case .hovering:
return "Click to Copy"
case .clicking:
return "Copied!"
case .normal:
fatalError()
}
}
var backgroundColor: Color {
let color: NSColor
switch interactionState {
case .normal:
color = .windowBackgroundColor
case .hovering:
color = .unemphasizedSelectedContentBackgroundColor
case .clicking:
color = .selectedContentBackgroundColor
}
return Color(color)
}
var primaryTextColor: Color {
let color: NSColor
switch interactionState {
case .normal, .hovering:
color = .textColor
case .clicking:
color = .white
}
return Color(color)
}
var secondaryTextColor: Color {
let color: NSColor
switch interactionState {
case .normal, .hovering:
color = .secondaryLabelColor
case .clicking:
color = .white
}
return Color(color)
}
func copy() {
NSPasteboard.general.declareTypes([.string], owner: nil)
NSPasteboard.general.setString(text, forType: .string)
}
private enum InteractionState {
case normal, hovering, clicking
}
}

View File

@ -1,168 +1,245 @@
import Foundation
import SwiftUI
struct SetupView: View {
var completion: ((Bool) -> Void)?
@State var completedSteps: Set<Step> = []
var body: some View {
Form {
SetupStepView<Spacer>(text: "Secretive needs to install a helper app to sign requests when the main app isn't running. This app is called \"SecretAgent\" and you might see it in Activity Manager from time to time.",
stepID: .agent,
nestedView: nil,
actionText: "Install") {
let success = installLaunchAgent()
if success {
completedSteps.insert(.agent)
}
return success
}
SetupStepView(text: "Add this line to your shell config telling SSH to talk to SecretAgent when it wants to authenticate. Drag this into your config file.",
stepID: .shellConfig,
nestedView: SetupStepCommandView(instructions: Constants.socketPrompts, selectedShellInstruction: Constants.socketPrompts.first!),
actionText: "Added") {
markAsDone(.shellConfig)
}
SetupStepView<Link>(text: "Secretive will periodically check with GitHub to see if there's a new release. If you see any network requests to GitHub, that's why.",
stepID: .updateNotice,
nestedView: Link("Read more about this here.", destination: Constants.updaterFAQURL),
actionText: "Got it") {
markAsDone(.updateNotice)
}
HStack {
Spacer()
Button("Finish") {
completion?(completedAllSteps)
}.disabled(!completedAllSteps)
.padding()
}
}.frame(minWidth: 640, minHeight: 400)
}
}
struct SetupStepView<NestedViewType: View>: View {
let text: String
let stepID: Step
let nestedView: NestedViewType?
@State var completed = false
let actionText: String
let action: (() -> Bool)
@State var stepIndex = 0
@Binding var visible: Bool
var body: some View {
Section {
HStack {
ZStack {
if completed {
Circle().foregroundColor(.green)
.frame(width: 30, height: 30)
Text("")
.foregroundColor(.white)
.bold()
} else {
Circle().foregroundColor(.blue)
.frame(width: 30, height: 30)
Text(String(describing: stepID.rawValue + 1))
.foregroundColor(.white)
.bold()
VStack {
StepView(numberOfSteps: 3, currentStep: stepIndex)
GeometryReader { proxy in
HStack {
SecretAgentSetupView(buttonAction: advance)
.frame(width: proxy.size.width)
SSHAgentSetupView(buttonAction: advance)
.frame(width: proxy.size.width)
UpdaterExplainerView {
visible = false
}
.frame(width: proxy.size.width)
}
.padding()
VStack {
Text(text)
.opacity(completed ? 0.5 : 1)
.lineLimit(nil)
if nestedView != nil {
nestedView!.padding()
}
}
.padding()
Button(actionText) {
completed = action()
}.frame(alignment: .trailing)
.disabled(completed)
.padding()
.offset(x: -proxy.size.width * CGFloat(stepIndex), y: 0)
.animation(.spring())
}
}
.frame(idealWidth: 500, idealHeight: 500)
}
func advance() {
stepIndex += 1
}
}
struct SetupStepCommandView: View {
let instructions: [ShellConfigInstruction]
struct SetupView_Previews: PreviewProvider {
@State var selectedShellInstruction: ShellConfigInstruction
static var previews: some View {
Group {
SetupView(visible: .constant(true))
}
}
}
struct StepView: View {
let numberOfSteps: Int
let currentStep: Int
var body: some View {
TabView(selection: $selectedShellInstruction) {
ForEach(instructions) { instruction in
VStack(alignment: .leading) {
Text(instruction.text)
.lineLimit(nil)
.font(.system(.caption, design: .monospaced))
.multilineTextAlignment(.leading)
.frame(minHeight: 50)
HStack {
Spacer()
Button(action: copy) {
Text("Copy")
ZStack {
Rectangle()
.foregroundColor(.blue)
.frame(height: 5)
HStack {
ForEach(0..<numberOfSteps) { index in
ZStack {
if currentStep > index {
Circle()
.foregroundColor(.green)
.frame(width: 30, height: 30)
Text("")
.foregroundColor(.white)
.bold()
} else {
Circle()
.foregroundColor(currentStep == index ? .white : .blue)
.frame(width: 30, height: 30)
Text(String(describing: index + 1))
.foregroundColor(currentStep == index ? .blue : .white)
.bold()
}
}
}.tabItem {
Text(instruction.shell)
if index < numberOfSteps - 1 {
Spacer(minLength: 30)
}
}
.tag(instruction)
.padding()
}
}
.onDrag {
return NSItemProvider(item: NSData(data: selectedShellInstruction.text.data(using: .utf8)!), typeIdentifier: kUTTypeUTF8PlainText as String)
.padding()
}
}
struct SetupStepView<Content> : View where Content : View {
let title: String
let image: Image
let bodyText: String
let buttonTitle: String
let buttonAction: () -> Void
let content: Content
init(title: String, image: Image, bodyText: String, buttonTitle: String, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
self.title = title
self.image = image
self.bodyText = bodyText
self.buttonTitle = buttonTitle
self.buttonAction = buttonAction
self.content = content()
}
var body: some View {
VStack {
Text(title)
.font(.title)
Spacer()
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 64)
Spacer()
Text(bodyText)
.multilineTextAlignment(.center)
Spacer()
content
Spacer()
Button(buttonTitle) {
buttonAction()
}
}
.padding()
}
}
struct SecretAgentSetupView: View {
let buttonAction: () -> Void
var body: some View {
SetupStepView(title: "Setup Secret Agent",
image: Image(nsImage: NSApp.applicationIconImage),
bodyText: "Secretive needs to set up a helper app to work properly. It will sign requests from SSH clients in the background, so you don't need to keep the main Secretive app open.",
buttonTitle: "Install",
buttonAction: install) {
(Text("This helper app is called ") + Text("Secret Agent").bold().underline() + Text(" and you may see it in Activity Manager from time to time."))
.multilineTextAlignment(.center)
}
}
func copy() {
NSPasteboard.general.declareTypes([.string], owner: nil)
NSPasteboard.general.setString(selectedShellInstruction.text, forType: .string)
func install() {
_ = LaunchAgentController().install()
buttonAction()
}
}
struct SecretAgentSetupView_Previews: PreviewProvider {
static var previews: some View {
Group {
SecretAgentSetupView(buttonAction: {})
}
}
}
struct SSHAgentSetupView: View {
@State var selectedShellInstruction: ShellConfigInstruction = SetupView.Constants.socketPrompts.first!
let buttonAction: () -> Void
var body: some View {
SetupStepView(title: "Configure your SSH Agent",
image: Image(systemName: "terminal"),
bodyText: "Add this line to your shell config telling SSH to talk to Secret Agent when it wants to authenticate. Drag this into your config file.",
buttonTitle: "Done",
buttonAction: buttonAction) {
Picker(selection: $selectedShellInstruction, label: EmptyView()) {
ForEach(SetupView.Constants.socketPrompts) { instruction in
Text(instruction.shell)
.tag(instruction)
.padding()
}
}.pickerStyle(SegmentedPickerStyle())
CopyableView(title: "Add to \(selectedShellInstruction.shellConfigPath)", image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
}
}
}
struct SSHAgentSetupView_Previews: PreviewProvider {
static var previews: some View {
Group {
SSHAgentSetupView(buttonAction: {})
}
}
}
struct UpdaterExplainerView: View {
let buttonAction: () -> Void
var body: some View {
SetupStepView(title: "Updates",
image: Image(systemName: "dot.radiowaves.left.and.right"),
bodyText: "Secretive will periodically check with GitHub to see if there's a new release. If you see any network requests to GitHub, that's why.",
buttonTitle: "Okay",
buttonAction: buttonAction) {
Link("Read more about this here.", destination: SetupView.Constants.updaterFAQURL)
}
}
}
struct UpdaterExplainerView_Previews: PreviewProvider {
static var previews: some View {
Group {
UpdaterExplainerView(buttonAction: {})
}
}
}
extension SetupView {
func installLaunchAgent() -> Bool {
LaunchAgentController().install()
}
func markAsDone(_ step: Step) -> Bool {
completedSteps.insert(step)
return true
}
var completedAllSteps: Bool {
completedSteps == Set(Step.allCases)
}
}
extension SetupView {
enum Constants {
static let socketPath = (NSHomeDirectory().replacingOccurrences(of: "com.maxgoedjen.Secretive.Host", with: "com.maxgoedjen.Secretive.SecretAgent") as NSString).appendingPathComponent("socket.ssh") as String
static let socketPrompts: [ShellConfigInstruction] = [
ShellConfigInstruction(shell: "zsh", text: "export SSH_AUTH_SOCK=\(socketPath)"),
ShellConfigInstruction(shell: "bash", text: "export SSH_AUTH_SOCK=\(socketPath)"),
ShellConfigInstruction(shell: "fish", text: "set -x SSH_AUTH_SOCK=\(socketPath)"),
ShellConfigInstruction(shell: "zsh",
shellConfigPath: "~/.zshrc",
text: "export SSH_AUTH_SOCK=\(socketPath)"),
ShellConfigInstruction(shell: "bash",
shellConfigPath: "~/.bashrc",
text: "export SSH_AUTH_SOCK=\(socketPath)"),
ShellConfigInstruction(shell: "fish",
shellConfigPath: "~/.config/fish/config.fish",
text: "set -x SSH_AUTH_SOCK=\(socketPath)"),
]
static let updaterFAQURL = URL(string: "https://github.com/maxgoedjen/secretive/blob/main/FAQ.md#whats-this-network-request-to-github")!
}
}
struct ShellConfigInstruction: Identifiable, Hashable {
var shell: String
var shellConfigPath: String
var text: String
var id: String {
@ -170,23 +247,3 @@ struct ShellConfigInstruction: Identifiable, Hashable {
}
}
enum Step: Int, Identifiable, Hashable, CaseIterable {
case agent, shellConfig, updateNotice
var id: Int {
rawValue
}
}
struct SetupView_Previews: PreviewProvider {
static var previews: some View {
Group {
SetupView()
SetupView()
.frame(width: 1500, height: 400)
}
}
}