mirror of
https://github.com/maxgoedjen/secretive.git
synced 2025-07-01 17:53:36 +00:00
Compare commits
3 Commits
viewbuilde
...
v2.3.0
Author | SHA1 | Date | |
---|---|---|---|
480ef5392d | |||
8679ca3da0 | |||
f43571baa3 |
@ -4,25 +4,6 @@ import OSLog
|
||||
import SecretKit
|
||||
import AppKit
|
||||
|
||||
enum OpenSSHCertificateError: Error {
|
||||
case unsupportedType
|
||||
case parsingFailed
|
||||
case doesNotExist
|
||||
}
|
||||
|
||||
extension OpenSSHCertificateError: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .unsupportedType:
|
||||
return "The key type was unsupported"
|
||||
case .parsingFailed:
|
||||
return "Failed to properly parse the SSH certificate"
|
||||
case .doesNotExist:
|
||||
return "Certificate does not exist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores.
|
||||
public class Agent {
|
||||
|
||||
@ -30,7 +11,7 @@ public class Agent {
|
||||
private let witness: SigningWitness?
|
||||
private let writer = OpenSSHKeyWriter()
|
||||
private let requestTracer = SigningRequestTracer()
|
||||
private let certsPath = (NSHomeDirectory() as NSString).appendingPathComponent("PublicKeys") as String
|
||||
private let certificateHandler = OpenSSHCertificateHandler()
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent.agent", category: "")
|
||||
|
||||
/// Initializes an agent with a store list and a witness.
|
||||
@ -41,6 +22,7 @@ public class Agent {
|
||||
logger.debug("Agent is running")
|
||||
self.storeList = storeList
|
||||
self.witness = witness
|
||||
certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
||||
}
|
||||
|
||||
}
|
||||
@ -102,7 +84,8 @@ extension Agent {
|
||||
/// Lists the identities available for signing operations
|
||||
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
||||
func identities() -> Data {
|
||||
let secrets = storeList.stores.flatMap(\.secrets)
|
||||
let secrets = storeList.allSecrets
|
||||
certificateHandler.reloadCertificates(for: secrets)
|
||||
var count = UInt32(secrets.count).bigEndian
|
||||
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
|
||||
var keyData = Data()
|
||||
@ -111,9 +94,9 @@ extension Agent {
|
||||
let keyBlob: Data
|
||||
let curveData: Data
|
||||
|
||||
if let (certBlob, certName) = try? checkForCert(secret: secret) {
|
||||
keyBlob = certBlob
|
||||
curveData = certName
|
||||
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) {
|
||||
keyBlob = certificateData
|
||||
curveData = name
|
||||
} else {
|
||||
keyBlob = writer.data(secret: secret)
|
||||
curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
|
||||
@ -134,11 +117,13 @@ extension Agent {
|
||||
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
||||
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
|
||||
let reader = OpenSSHReader(data: data)
|
||||
var hash = reader.readNextChunk()
|
||||
|
||||
let payloadHash = reader.readNextChunk()
|
||||
let hash: Data
|
||||
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
|
||||
if let certPublicKey = try? getPublicKeyFromCert(certBlob: hash) {
|
||||
hash = certPublicKey
|
||||
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
|
||||
hash = certificatePublicKey
|
||||
} else {
|
||||
hash = payloadHash
|
||||
}
|
||||
|
||||
guard let (store, secret) = secret(matching: hash) else {
|
||||
@ -200,74 +185,6 @@ extension Agent {
|
||||
|
||||
return signedData
|
||||
}
|
||||
|
||||
/// Reconstructs a public key from a ``Data`` object that contains an OpenSSH certificate. Currently only ecdsa certificates are supported
|
||||
/// - Parameter certBlock: The openssh certificate to extract the public key from
|
||||
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format
|
||||
func getPublicKeyFromCert(certBlob: Data) throws -> Data {
|
||||
let reader = OpenSSHReader(data: certBlob)
|
||||
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
|
||||
|
||||
switch certType {
|
||||
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
|
||||
|
||||
_ = reader.readNextChunk() // nonce
|
||||
let curveIdentifier = reader.readNextChunk()
|
||||
let publicKey = reader.readNextChunk()
|
||||
|
||||
if let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8) {
|
||||
return writer.lengthAndData(of: curveType) +
|
||||
writer.lengthAndData(of: curveIdentifier) +
|
||||
writer.lengthAndData(of: publicKey)
|
||||
} else {
|
||||
throw OpenSSHCertificateError.parsingFailed
|
||||
}
|
||||
default:
|
||||
throw OpenSSHCertificateError.unsupportedType
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
||||
/// - Parameter secret: The secret to search for a certificate with
|
||||
/// - Returns: Two ``Data`` objects containing the certificate and certificate name respectively
|
||||
func checkForCert(secret: AnySecret) throws -> (Data, Data) {
|
||||
let minimalHex = writer.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||
let certificatePath = certsPath.appending("/").appending("\(minimalHex)-cert.pub")
|
||||
|
||||
if FileManager.default.fileExists(atPath: certificatePath) {
|
||||
logger.debug("Found certificate for \(secret.name)")
|
||||
do {
|
||||
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
|
||||
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
|
||||
|
||||
if certElements.count >= 2 {
|
||||
if let certDecoded = Data(base64Encoded: certElements[1] as String) {
|
||||
if certElements.count >= 3 {
|
||||
if let certName = certElements[2].data(using: .utf8) {
|
||||
return (certDecoded, certName)
|
||||
} else if let certName = secret.name.data(using: .utf8) {
|
||||
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
|
||||
return (certDecoded, certName)
|
||||
} else {
|
||||
throw OpenSSHCertificateError.parsingFailed
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
|
||||
throw OpenSSHCertificateError.parsingFailed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.warning("Certificate found for \(secret.name) but failed to load")
|
||||
throw OpenSSHCertificateError.parsingFailed
|
||||
}
|
||||
}
|
||||
|
||||
throw OpenSSHCertificateError.doesNotExist
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -308,6 +225,7 @@ extension Agent {
|
||||
case unhandledType
|
||||
case noMatchingKey
|
||||
case unsupportedKeyType
|
||||
case notOpenSSHCertificate
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// A namespace for the SSH Agent Protocol, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html
|
||||
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||
public enum SSHAgent {}
|
||||
|
||||
extension SSHAgent {
|
||||
|
||||
/// The type of the SSH Agent Request, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html#rfc.section.5.1
|
||||
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||
public enum RequestType: UInt8, CustomDebugStringConvertible {
|
||||
|
||||
case requestIdentities = 11
|
||||
@ -21,10 +21,11 @@ extension SSHAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of the SSH Agent Response, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html#rfc.section.5.1
|
||||
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||
public enum ResponseType: UInt8, CustomDebugStringConvertible {
|
||||
|
||||
case agentFailure = 5
|
||||
case agentSuccess = 6
|
||||
case agentIdentitiesAnswer = 12
|
||||
case agentSignResponse = 14
|
||||
|
||||
@ -32,6 +33,8 @@ extension SSHAgent {
|
||||
switch self {
|
||||
case .agentFailure:
|
||||
return "AgentFailure"
|
||||
case .agentSuccess:
|
||||
return "AgentSuccess"
|
||||
case .agentIdentitiesAnswer:
|
||||
return "AgentIdentitiesAnswer"
|
||||
case .agentSignResponse:
|
||||
|
@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Manages storage and lookup for OpenSSH certificates.
|
||||
public class OpenSSHCertificateHandler {
|
||||
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
||||
private let logger = Logger()
|
||||
private let writer = OpenSSHKeyWriter()
|
||||
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
||||
|
||||
/// Initializes an OpenSSHCertificateHandler.
|
||||
public init() {
|
||||
}
|
||||
|
||||
/// Reloads any certificates in the PublicKeys folder.
|
||||
/// - Parameter secrets: the secrets to look up corresponding certificates for.
|
||||
public func reloadCertificates(for secrets: [AnySecret]) {
|
||||
guard publicKeyFileStoreController.hasAnyCertificates else {
|
||||
logger.log("No certificates, short circuiting")
|
||||
return
|
||||
}
|
||||
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
|
||||
partialResult[next] = try? loadKeyblobAndName(for: next)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not the certificate handler has a certifiicate associated with a given secret.
|
||||
/// - Parameter secret: The secret to check for a certificate.
|
||||
/// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret
|
||||
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
|
||||
keyBlobsAndNames[AnySecret(secret)] != nil
|
||||
}
|
||||
|
||||
|
||||
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
|
||||
/// - Parameter certBlock: The openssh certificate to extract the public key from
|
||||
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
|
||||
public func publicKeyHash(from hash: Data) -> Data? {
|
||||
let reader = OpenSSHReader(data: hash)
|
||||
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
|
||||
|
||||
switch certType {
|
||||
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
||||
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
|
||||
_ = reader.readNextChunk() // nonce
|
||||
let curveIdentifier = reader.readNextChunk()
|
||||
let publicKey = reader.readNextChunk()
|
||||
|
||||
let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)!
|
||||
return writer.lengthAndData(of: curveType) +
|
||||
writer.lengthAndData(of: curveIdentifier) +
|
||||
writer.lengthAndData(of: publicKey)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
||||
/// - Parameter secret: The secret to search for a certificate with
|
||||
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
||||
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
|
||||
keyBlobsAndNames[AnySecret(secret)]
|
||||
}
|
||||
|
||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
||||
/// - Parameter secret: The secret to search for a certificate with
|
||||
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
|
||||
private func loadKeyblobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
|
||||
let certificatePath = publicKeyFileStoreController.sshCertificatePath(for: secret)
|
||||
guard FileManager.default.fileExists(atPath: certificatePath) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.debug("Found certificate for \(secret.name)")
|
||||
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
|
||||
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
|
||||
|
||||
guard certElements.count >= 2 else {
|
||||
logger.warning("Certificate found for \(secret.name) but failed to load")
|
||||
throw OpenSSHCertificateError.parsingFailed
|
||||
}
|
||||
guard let certDecoded = Data(base64Encoded: certElements[1] as String) else {
|
||||
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
|
||||
throw OpenSSHCertificateError.parsingFailed
|
||||
}
|
||||
|
||||
if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) {
|
||||
return (certDecoded, certName)
|
||||
} else if let certName = secret.name.data(using: .utf8) {
|
||||
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
|
||||
return (certDecoded, certName)
|
||||
} else {
|
||||
throw OpenSSHCertificateError.parsingFailed
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension OpenSSHCertificateHandler {
|
||||
|
||||
enum OpenSSHCertificateError: LocalizedError {
|
||||
case unsupportedType
|
||||
case parsingFailed
|
||||
case doesNotExist
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedType:
|
||||
return "The key type was unsupported"
|
||||
case .parsingFailed:
|
||||
return "Failed to properly parse the SSH certificate"
|
||||
case .doesNotExist:
|
||||
return "Certificate does not exist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -45,6 +45,18 @@ public class PublicKeyFileStoreController {
|
||||
return directory.appending("/").appending("\(minimalHex).pub")
|
||||
}
|
||||
|
||||
/// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
|
||||
public var hasAnyCertificates: Bool {
|
||||
do {
|
||||
return try FileManager.default
|
||||
.contentsOfDirectory(atPath: directory)
|
||||
.filter { $0.hasSuffix("-cert.pub") }
|
||||
.isEmpty == false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// The path for a Secret's SSH Certificate public key.
|
||||
/// - Parameter secret: The Secret to return the path for.
|
||||
/// - Returns: The path to the SSH Certificate public key.
|
||||
|
@ -8,7 +8,7 @@ public class SecretStoreList: ObservableObject {
|
||||
@Published public var stores: [AnySecretStore] = []
|
||||
/// A modifiable store, if one is available.
|
||||
@Published public var modifiableStore: AnySecretStoreModifiable?
|
||||
private var sinks: [AnyCancellable] = []
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
/// Initializes a SecretStoreList.
|
||||
public init() {
|
||||
@ -31,16 +31,19 @@ public class SecretStoreList: ObservableObject {
|
||||
stores.reduce(false, { $0 || $1.isAvailable })
|
||||
}
|
||||
|
||||
public var allSecrets: [AnySecret] {
|
||||
stores.flatMap(\.secrets)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SecretStoreList {
|
||||
|
||||
private func addInternal(store: AnySecretStore) {
|
||||
stores.append(store)
|
||||
let sink = store.objectWillChange.sink {
|
||||
store.objectWillChange.sink {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
sinks.append(sink)
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -34,9 +34,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
self.socketController.handler = self.agent.handle(reader:writer:)
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in
|
||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
|
||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||
}
|
||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
|
||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||
notifier.prompt()
|
||||
updateSink = updater.$update.sink { update in
|
||||
guard let update = update else { return }
|
||||
|
@ -31,6 +31,7 @@
|
||||
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */; };
|
||||
50617D9923FCE48E0099B055 /* SecretiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D9823FCE48E0099B055 /* SecretiveTests.swift */; };
|
||||
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617DD123FCEFA90099B055 /* PreviewStore.swift */; };
|
||||
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5065E312295517C500E16645 /* ToolbarButtonStyle.swift */; };
|
||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C12516F303004B5A36 /* SetupView.swift */; };
|
||||
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; };
|
||||
5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */; };
|
||||
@ -123,6 +124,7 @@
|
||||
50617D9823FCE48E0099B055 /* SecretiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretiveTests.swift; sourceTree = "<group>"; };
|
||||
50617D9A23FCE48E0099B055 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50617DD123FCEFA90099B055 /* PreviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewStore.swift; sourceTree = "<group>"; };
|
||||
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarButtonStyle.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>"; };
|
||||
5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellConfigurationController.swift; sourceTree = "<group>"; };
|
||||
@ -264,6 +266,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50617D8423FCE48E0099B055 /* ContentView.swift */,
|
||||
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
|
||||
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
|
||||
50153E21250DECA300525160 /* SecretListItemView.swift */,
|
||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
||||
@ -475,6 +478,7 @@
|
||||
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */,
|
||||
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
||||
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
|
||||
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
|
||||
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
|
||||
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
|
||||
|
@ -9,6 +9,9 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
||||
@Binding var showingCreation: Bool
|
||||
@Binding var runningSetup: Bool
|
||||
@Binding var hasRunSetup: Bool
|
||||
@State var showingAgentInfo = false
|
||||
@State var activeSecret: AnySecret.ID?
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@EnvironmentObject private var storeList: SecretStoreList
|
||||
@EnvironmentObject private var updater: UpdaterType
|
||||
@ -20,17 +23,17 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
||||
var body: some View {
|
||||
VStack {
|
||||
if storeList.anyAvailable {
|
||||
StoreListView(showingCreation: $showingCreation)
|
||||
StoreListView(activeSecret: $activeSecret)
|
||||
} else {
|
||||
NoStoresView()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 320)
|
||||
.toolbar {
|
||||
updateNotice
|
||||
setupNotice
|
||||
appPathNotice
|
||||
newItem
|
||||
toolbarItem(updateNoticeView, id: "update")
|
||||
toolbarItem(runningOrRunSetupView, id: "setup")
|
||||
toolbarItem(appPathNoticeView, id: "appPath")
|
||||
toolbarItem(newItemView, id: "new")
|
||||
}
|
||||
.sheet(isPresented: $runningSetup) {
|
||||
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
|
||||
@ -41,121 +44,145 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
||||
|
||||
extension ContentView {
|
||||
|
||||
var updateNotice: ToolbarItem<Void, AnyView> {
|
||||
guard let update = updater.update else {
|
||||
return ToolbarItem { AnyView(EmptyView()) }
|
||||
|
||||
func toolbarItem(_ view: some View, id: String) -> ToolbarItem<String, some View> {
|
||||
ToolbarItem(id: id) { view }
|
||||
}
|
||||
|
||||
var needsSetup: Bool {
|
||||
(runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
runningNoticeView
|
||||
}
|
||||
let color: Color
|
||||
let text: String
|
||||
}
|
||||
|
||||
var updateNoticeContent: (String, Color)? {
|
||||
guard let update = updater.update else { return nil }
|
||||
if update.critical {
|
||||
text = "Critical Security Update Required"
|
||||
color = .red
|
||||
return ("Critical Security Update Required", .red)
|
||||
} else {
|
||||
if updater.testBuild {
|
||||
text = "Test Build"
|
||||
color = .blue
|
||||
return ("Test Build", .blue)
|
||||
} else {
|
||||
text = "Update Available"
|
||||
color = .orange
|
||||
return ("Update Available", .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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var newItem: ToolbarItem<Void, AnyView> {
|
||||
guard storeList.modifiableStore?.isAvailable ?? false else {
|
||||
return ToolbarItem { AnyView(EmptyView()) }
|
||||
}
|
||||
return ToolbarItem {
|
||||
AnyView(
|
||||
Button(action: {
|
||||
showingCreation = true
|
||||
}, label: {
|
||||
Image(systemName: "plus")
|
||||
})
|
||||
.sheet(isPresented: $showingCreation) {
|
||||
if let modifiable = storeList.modifiableStore {
|
||||
CreateSecretView(store: modifiable, showing: $showingCreation)
|
||||
}
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var setupNotice: ToolbarItem<Void, AnyView> {
|
||||
return ToolbarItem {
|
||||
AnyView(
|
||||
Group {
|
||||
if (runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild {
|
||||
Button(action: {
|
||||
runningSetup = true
|
||||
}, label: {
|
||||
Group {
|
||||
if hasRunSetup && !agentStatusChecker.running {
|
||||
Text("Secret Agent Is Not Running")
|
||||
} else {
|
||||
Text("Setup Secretive")
|
||||
}
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
})
|
||||
.background(Color.orange)
|
||||
.cornerRadius(5)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var appPathNotice: ToolbarItem<Void, AnyView> {
|
||||
let controller = ApplicationDirectoryController()
|
||||
guard !controller.isInApplicationsDirectory else {
|
||||
return ToolbarItem { AnyView(EmptyView()) }
|
||||
}
|
||||
return ToolbarItem {
|
||||
AnyView(
|
||||
Button(action: {
|
||||
showingAppPathNotice = true
|
||||
}, label: {
|
||||
Group {
|
||||
Text("Secretive Is Not in Applications Folder")
|
||||
}
|
||||
@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)
|
||||
})
|
||||
.background(Color.orange)
|
||||
.cornerRadius(5)
|
||||
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
|
||||
VStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 64)
|
||||
Text("Secretive needs to be in your Applications folder to work properly. Please move it and relaunch.")
|
||||
.frame(maxWidth: 300)
|
||||
}
|
||||
.padding()
|
||||
})
|
||||
.buttonStyle(ToolbarButtonStyle(color: color))
|
||||
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
|
||||
UpdateDetailView(update: update)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var newItemView: some View {
|
||||
if storeList.modifiableStore?.isAvailable ?? false {
|
||||
Button(action: {
|
||||
showingCreation = true
|
||||
}, label: {
|
||||
Image(systemName: "plus")
|
||||
})
|
||||
.sheet(isPresented: $showingCreation) {
|
||||
if let modifiable = storeList.modifiableStore {
|
||||
CreateSecretView(store: modifiable, showing: $showingCreation)
|
||||
.onDisappear {
|
||||
guard let newest = modifiable.secrets.last?.id else { return }
|
||||
activeSecret = newest
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var setupNoticeView: some View {
|
||||
Button(action: {
|
||||
runningSetup = true
|
||||
}, label: {
|
||||
Group {
|
||||
if hasRunSetup && !agentStatusChecker.running {
|
||||
Text("Secret Agent Is Not Running")
|
||||
} else {
|
||||
Text("Setup Secretive")
|
||||
}
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
})
|
||||
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var runningNoticeView: some View {
|
||||
Button(action: {
|
||||
showingAgentInfo = true
|
||||
}, label: {
|
||||
HStack {
|
||||
Text("Agent is Running")
|
||||
.font(.headline)
|
||||
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
|
||||
Circle()
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(Color.green)
|
||||
}
|
||||
})
|
||||
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
|
||||
.popover(isPresented: $showingAgentInfo, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
|
||||
VStack {
|
||||
Text("SecretAgent is Running")
|
||||
.font(.title)
|
||||
.padding(5)
|
||||
Text("SecretAgent is a process that runs in the background to sign requests, so you don't need to keep Secretive open all the time.\n\n**You can close Secretive, and everything will still keep working.**")
|
||||
.frame(width: 300)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var appPathNoticeView: some View {
|
||||
if !ApplicationDirectoryController().isInApplicationsDirectory {
|
||||
Button(action: {
|
||||
showingAppPathNotice = true
|
||||
}, label: {
|
||||
Group {
|
||||
Text("Secretive Is Not in Applications Folder")
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
})
|
||||
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
||||
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
|
||||
VStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 64)
|
||||
Text("Secretive needs to be in your Applications folder to work properly. Please move it and relaunch.")
|
||||
.frame(maxWidth: 300)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -198,3 +225,4 @@ struct ContentView_Previews: PreviewProvider {
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
@ -35,7 +35,6 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
|
||||
Spacer()
|
||||
Button("Delete", action: delete)
|
||||
.disabled(confirm != secret.name)
|
||||
.keyboardShortcut(.delete)
|
||||
Button("Don't Delete") {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
|
@ -29,7 +29,8 @@ struct SecretListItemView: View {
|
||||
} else {
|
||||
Text(secret.name)
|
||||
}
|
||||
}.contextMenu {
|
||||
}
|
||||
.contextMenu {
|
||||
if store is AnySecretStoreModifiable {
|
||||
Button(action: { isRenaming = true }) {
|
||||
Text("Rename")
|
||||
|
@ -1,12 +1,11 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import SecretKit
|
||||
|
||||
struct StoreListView: View {
|
||||
|
||||
@Binding var showingCreation: Bool
|
||||
@Binding var activeSecret: AnySecret.ID?
|
||||
|
||||
@State private var activeSecret: AnySecret.ID?
|
||||
|
||||
@EnvironmentObject private var storeList: SecretStoreList
|
||||
|
||||
private func secretDeleted(secret: AnySecret) {
|
||||
@ -14,7 +13,7 @@ struct StoreListView: View {
|
||||
}
|
||||
|
||||
private func secretRenamed(secret: AnySecret) {
|
||||
activeSecret = nextDefaultSecret
|
||||
activeSecret = secret.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
37
Sources/Secretive/Views/ToolbarButtonStyle.swift
Normal file
37
Sources/Secretive/Views/ToolbarButtonStyle.swift
Normal file
@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToolbarButtonStyle: ButtonStyle {
|
||||
|
||||
private let lightColor: Color
|
||||
private let darkColor: Color
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State var hovering = false
|
||||
|
||||
init(color: Color) {
|
||||
self.lightColor = color
|
||||
self.darkColor = color
|
||||
}
|
||||
|
||||
init(lightColor: Color, darkColor: Color) {
|
||||
self.lightColor = lightColor
|
||||
self.darkColor = darkColor
|
||||
}
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
|
||||
.background(colorScheme == .light ? lightColor : darkColor)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.stroke(colorScheme == .light ? .black.opacity(0.15) : .white.opacity(0.15), lineWidth: 1)
|
||||
.background(hovering ? (colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.05)) : Color.clear)
|
||||
)
|
||||
.onHover { hovering in
|
||||
withAnimation {
|
||||
self.hovering = hovering
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user