Compare commits

..

6 Commits

Author SHA1 Message Date
Max Goedjen
ff842ee2d9 WIP new setup page 2025-08-11 19:19:14 -07:00
Max Goedjen
374da84128 Merge branch 'swift6' into xcode_26 2025-08-10 23:15:34 -07:00
Max Goedjen
3b7d0f664e Merge branch 'swift6' of github.com:maxgoedjen/secretive into swift6 2025-08-10 23:08:29 -07:00
Max Goedjen
8b428e6c64 More cleanup 2025-08-10 23:08:17 -07:00
Max Goedjen
1196530e27 Fixed tests. 2025-08-10 20:14:07 -07:00
Max Goedjen
413af25169 Merge branch 'main' into swift6 2025-08-10 14:41:59 -07:00
18 changed files with 411 additions and 191 deletions

View File

@@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "SecretivePackages", name: "SecretivePackages",
platforms: [ platforms: [
.macOS(.v15) .macOS(.v14)
], ],
products: [ products: [
.library( .library(
@@ -27,13 +27,16 @@ let package = Package(
.library( .library(
name: "Brief", name: "Brief",
targets: ["Brief"]), targets: ["Brief"]),
.library(
name: "Common",
targets: ["Common"]),
], ],
dependencies: [ dependencies: [
], ],
targets: [ targets: [
.target( .target(
name: "SecretKit", name: "SecretKit",
dependencies: [], dependencies: ["Common"],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.testTarget( .testTarget(
@@ -43,17 +46,17 @@ let package = Package(
), ),
.target( .target(
name: "SecureEnclaveSecretKit", name: "SecureEnclaveSecretKit",
dependencies: ["SecretKit"], dependencies: ["Common", "SecretKit"],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.target( .target(
name: "SmartCardSecretKit", name: "SmartCardSecretKit",
dependencies: ["SecretKit"], dependencies: ["Common", "SecretKit"],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.target( .target(
name: "SecretAgentKit", name: "SecretAgentKit",
dependencies: ["SecretKit", "SecretAgentKitHeaders"], dependencies: ["Common", "SecretKit", "SecretAgentKitHeaders"],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.systemLibrary( .systemLibrary(
@@ -65,13 +68,18 @@ let package = Package(
, ,
.target( .target(
name: "Brief", name: "Brief",
dependencies: [], dependencies: ["Common"],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.testTarget( .testTarget(
name: "BriefTests", name: "BriefTests",
dependencies: ["Brief"] dependencies: ["Brief"]
), ),
.target(
name: "Common",
dependencies: [],
swiftSettings: swiftSettings
),
] ]
) )

View File

@@ -1,14 +1,15 @@
import Foundation import Foundation
import Observation import Observation
import Synchronization import os
import Common
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version. /// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
@Observable public final class Updater: UpdaterProtocol, ObservableObject, Sendable { @Observable public final class Updater: UpdaterProtocol, ObservableObject, Sendable {
public var update: Release? { public var update: Release? {
_update.withLock { $0 } _update.lockedValue
} }
private let _update: Mutex<Release?> = .init(nil) private let _update: OSAllocatedUnfairLock<Release?> = .init(uncheckedState: nil)
public let testBuild: Bool public let testBuild: Bool
/// The current OS version. /// The current OS version.
@@ -53,9 +54,7 @@ import Synchronization
guard !release.critical else { return } guard !release.critical else { return }
defaults.set(true, forKey: release.name) defaults.set(true, forKey: release.name)
await MainActor.run { await MainActor.run {
_update.withLock { value in _update.lockedValue = nil
value = nil
}
} }
} }
@@ -76,9 +75,7 @@ extension Updater {
let latestVersion = SemVer(release.name) let latestVersion = SemVer(release.name)
if latestVersion > currentVersion { if latestVersion > currentVersion {
await MainActor.run { await MainActor.run {
_update.withLock { value in _update.lockedValue = release
value = release
}
} }
} }
} }

View File

@@ -1,5 +1,5 @@
import Foundation import Foundation
import Synchronization import os
/// A protocol for retreiving the latest available version of an app. /// A protocol for retreiving the latest available version of an app.
public protocol UpdaterProtocol: Observable { public protocol UpdaterProtocol: Observable {

View File

@@ -0,0 +1,14 @@
import os
public extension OSAllocatedUnfairLock where State: Sendable {
var lockedValue: State {
get {
withLock { $0 }
}
nonmutating set {
withLock { $0 = newValue }
}
}
}

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
import OSLog import OSLog
import Synchronization import os
/// Manages storage and lookup for OpenSSH certificates. /// Manages storage and lookup for OpenSSH certificates.
public final class OpenSSHCertificateHandler: Sendable { public final class OpenSSHCertificateHandler: Sendable {
@@ -8,7 +8,7 @@ public final class OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHKeyWriter() private let writer = OpenSSHKeyWriter()
private let keyBlobsAndNames: Mutex<[AnySecret: (Data, Data)]> = .init([:]) private let keyBlobsAndNames: OSAllocatedUnfairLock<[AnySecret: (Data, Data)]> = .init(uncheckedState: [:])
/// Initializes an OpenSSHCertificateHandler. /// Initializes an OpenSSHCertificateHandler.
public init() { public init() {
@@ -32,10 +32,7 @@ public final class OpenSSHCertificateHandler: Sendable {
/// - Parameter secret: The secret to check for a certificate. /// - 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 /// - 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 { public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
keyBlobsAndNames.withLock { keyBlobsAndNames.lockedValue[AnySecret(secret)] != nil
$0[AnySecret(secret)] != nil
}
} }
@@ -67,9 +64,7 @@ public final class OpenSSHCertificateHandler: Sendable {
/// - Parameter secret: The secret to search for a certificate with /// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? { public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
keyBlobsAndNames.withLock { keyBlobsAndNames.lockedValue[AnySecret(secret)]
$0[AnySecret(secret)]
}
} }
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``

View File

@@ -1,21 +1,22 @@
import Foundation import Foundation
import Observation import Observation
import Synchronization import os
import Common
/// A "Store Store," which holds a list of type-erased stores. /// A "Store Store," which holds a list of type-erased stores.
@Observable public final class SecretStoreList: Sendable { @Observable public final class SecretStoreList: Sendable {
/// The Stores managed by the SecretStoreList. /// The Stores managed by the SecretStoreList.
public var stores: [AnySecretStore] { public var stores: [AnySecretStore] {
__stores.withLock { $0 } __stores.lockedValue
} }
private let __stores: Mutex<[AnySecretStore]> = .init([]) private let __stores: OSAllocatedUnfairLock<[AnySecretStore]> = .init(uncheckedState: [])
/// A modifiable store, if one is available. /// A modifiable store, if one is available.
public var modifiableStore: AnySecretStoreModifiable? { public var modifiableStore: AnySecretStoreModifiable? {
__modifiableStore.withLock { $0 } __modifiableStore.withLock { $0 }
} }
private let __modifiableStore: Mutex<AnySecretStoreModifiable?> = .init(nil) private let __modifiableStore: OSAllocatedUnfairLock<AnySecretStoreModifiable?> = .init(uncheckedState: nil)
/// Initializes a SecretStoreList. /// Initializes a SecretStoreList.
public init() { public init() {
@@ -31,9 +32,7 @@ import Synchronization
/// Adds a non-type-erased modifiable SecretStore. /// Adds a non-type-erased modifiable SecretStore.
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) { public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
let modifiable = AnySecretStoreModifiable(modifiable: store) let modifiable = AnySecretStoreModifiable(modifiable: store)
__modifiableStore.withLock { __modifiableStore.lockedValue = modifiable
$0 = modifiable
}
__stores.withLock { __stores.withLock {
$0.append(modifiable) $0.append(modifiable)
} }
@@ -41,15 +40,11 @@ import Synchronization
/// A boolean describing whether there are any Stores available. /// A boolean describing whether there are any Stores available.
public var anyAvailable: Bool { public var anyAvailable: Bool {
__stores.withLock { __stores.lockedValue.contains(where: \.isAvailable)
$0.reduce(false, { $0 || $1.isAvailable })
}
} }
public var allSecrets: [AnySecret] { public var allSecrets: [AnySecret] {
__stores.withLock { __stores.lockedValue.flatMap(\.secrets)
$0.flatMap(\.secrets)
}
} }
} }

View File

@@ -2,7 +2,7 @@ import Foundation
import AppKit import AppKit
/// Describes the chain of applications that requested a signature operation. /// Describes the chain of applications that requested a signature operation.
public struct SigningRequestProvenance: Equatable { public struct SigningRequestProvenance: Equatable, Sendable {
/// A list of processes involved in the request. /// A list of processes involved in the request.
/// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app` /// - Note: A chain will typically consist of many elements even for a simple request. For example, running `git fetch` in Terminal.app would generate a request chain of `ssh` -> `git` -> `zsh` -> `login` -> `Terminal.app`
@@ -30,7 +30,7 @@ extension SigningRequestProvenance {
extension SigningRequestProvenance { extension SigningRequestProvenance {
/// Describes a process in a `SigningRequestProvenance` chain. /// Describes a process in a `SigningRequestProvenance` chain.
public struct Process: Equatable { public struct Process: Equatable, Sendable {
/// The pid of the process. /// The pid of the process.
public let pid: Int32 public let pid: Int32

View File

@@ -4,7 +4,8 @@ import Security
import CryptoKit import CryptoKit
@preconcurrency import LocalAuthentication @preconcurrency import LocalAuthentication
import SecretKit import SecretKit
import Synchronization import os
import Common
extension SecureEnclave { extension SecureEnclave {
@@ -17,11 +18,11 @@ extension SecureEnclave {
public let id = UUID() public let id = UUID()
public let name = String(localized: "secure_enclave") public let name = String(localized: "secure_enclave")
public var secrets: [Secret] { public var secrets: [Secret] {
_secrets.withLock { $0 } _secrets.lockedValue
} }
private let _secrets: Mutex<[Secret]> = .init([]) private let _secrets: OSAllocatedUnfairLock<[Secret]> = .init(uncheckedState: [])
private let persistedAuthenticationContexts: Mutex<[Secret: PersistentAuthenticationContext]> = .init([:]) private let persistedAuthenticationContexts: OSAllocatedUnfairLock<[Secret: PersistentAuthenticationContext]> = .init(uncheckedState: [:])
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
@@ -105,42 +106,40 @@ extension SecureEnclave {
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
let context: Mutex<LAContext> var context: LAContext
// if let existing = persistedAuthenticationContexts.withLock({ $0 })[secret], existing.valid { if let existing = persistedAuthenticationContexts.lockedValue[secret], existing.valid {
// context = existing.context context = existing.context
// } else { } else {
let newContext = LAContext() let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button") newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
context = .init(newContext) context = newContext
// }
return try context.withLock { context in
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
} }
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
} }
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool { public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
@@ -179,7 +178,7 @@ extension SecureEnclave {
} }
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts.withLock({ $0 })[secret], persisted.valid else { return nil } guard let persisted = persistedAuthenticationContexts.lockedValue[secret], persisted.valid else { return nil }
return persisted return persisted
} }

View File

@@ -1,5 +1,5 @@
import Foundation import Foundation
import Synchronization import os
import Observation import Observation
import Security import Security
import CryptoTokenKit import CryptoTokenKit
@@ -19,7 +19,7 @@ extension SmartCard {
/// An implementation of Store backed by a Smart Card. /// An implementation of Store backed by a Smart Card.
@Observable public final class Store: SecretStore { @Observable public final class Store: SecretStore {
private let state: Mutex<State> = .init(.init()) private let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init())
public var isAvailable: Bool { public var isAvailable: Bool {
state.withLock { $0.isAvailable } state.withLock { $0.isAvailable }
} }

View File

@@ -2,7 +2,6 @@ import Testing
import Foundation import Foundation
@testable import Brief @testable import Brief
@Suite struct ReleaseParsingTests { @Suite struct ReleaseParsingTests {
@Test @Test

View File

@@ -1,8 +1,10 @@
import Foundation import Foundation
import os
import Testing import Testing
import CryptoKit import CryptoKit
@testable import SecretKit @testable import SecretKit
@testable import SecretAgentKit @testable import SecretAgentKit
import Common
@Suite struct AgentTests { @Suite struct AgentTests {
@@ -90,34 +92,35 @@ import CryptoKit
@Test func witnessSignature() async { @Test func witnessSignature() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
var witnessed = false let witnessed: OSAllocatedUnfairLock<Bool> = .init(uncheckedState: false)
let witness = StubWitness(speakNow: { _, trace in let witness = StubWitness(speakNow: { _, trace in
return false return false
}, witness: { _, trace in }, witness: { _, trace in
witnessed = true witnessed.lockedValue = true
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
#expect(witnessed) let value = witnessed.lockedValue
#expect(value)
} }
@Test func requestTracing() async { @Test func requestTracing() async {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let list = storeList(with: [Constants.Secrets.ecdsa256Secret]) let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
var speakNowTrace: SigningRequestProvenance! = nil let speakNowTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil)
var witnessTrace: SigningRequestProvenance! = nil let witnessTrace: OSAllocatedUnfairLock<SigningRequestProvenance?> = .init(uncheckedState: nil)
let witness = StubWitness(speakNow: { _, trace in let witness = StubWitness(speakNow: { _, trace in
speakNowTrace = trace speakNowTrace.lockedValue = trace
return false return false
}, witness: { _, trace in }, witness: { _, trace in
witnessTrace = trace witnessTrace.lockedValue = trace
}) })
let agent = Agent(storeList: list, witness: witness) let agent = Agent(storeList: list, witness: witness)
await agent.handle(reader: stubReader, writer: stubWriter) await agent.handle(reader: stubReader, writer: stubWriter)
#expect(witnessTrace == speakNowTrace) #expect(witnessTrace.lockedValue == speakNowTrace.lockedValue)
#expect(witnessTrace.origin.displayName == "Finder") #expect(witnessTrace.lockedValue?.origin.displayName == "Finder")
#expect(witnessTrace.origin.validSignature == true) #expect(witnessTrace.lockedValue?.origin.validSignature == true)
#expect(witnessTrace.origin.parentPID == 1) #expect(witnessTrace.lockedValue?.origin.parentPID == 1)
} }
// MARK: Exception Handling // MARK: Exception Handling

View File

@@ -3,8 +3,8 @@ import SecretAgentKit
struct StubWitness { struct StubWitness {
let speakNow: (AnySecret, SigningRequestProvenance) -> Bool let speakNow: @Sendable (AnySecret, SigningRequestProvenance) -> Bool
let witness: (AnySecret, SigningRequestProvenance) -> () let witness: @Sendable (AnySecret, SigningRequestProvenance) -> ()
} }

View File

@@ -4,7 +4,7 @@ import AppKit
import SecretKit import SecretKit
import SecretAgentKit import SecretAgentKit
import Brief import Brief
import Synchronization import os
final class Notifier: Sendable { final class Notifier: Sendable {
@@ -84,10 +84,10 @@ final class Notifier: Sendable {
try? await notificationCenter.add(request) try? await notificationCenter.add(request)
} }
func notify(update: Release, ignore: ((Release) -> Void)?) { func notify(update: Release, ignore: (@Sendable (Release) -> Void)?) {
notificationDelegate.state.withLock { [update] state in notificationDelegate.state.withLock { [update] state in
state.release = update state.release = update
// state.ignore = ignore state.ignore = ignore
} }
let notificationCenter = UNUserNotificationCenter.current() let notificationCenter = UNUserNotificationCenter.current()
let notificationContent = UNMutableNotificationContent() let notificationContent = UNMutableNotificationContent()
@@ -141,7 +141,7 @@ extension Notifier {
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable { final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
struct State { struct State {
typealias PersistAuthentication = ((AnySecret, AnySecretStore, TimeInterval?) async -> Void) typealias PersistAuthentication = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
typealias Ignore = ((Release) -> Void) typealias Ignore = ((Release) -> Void)
fileprivate var release: Release? fileprivate var release: Release?
fileprivate var ignore: Ignore? fileprivate var ignore: Ignore?
@@ -151,7 +151,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:] fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
} }
fileprivate let state: Mutex<State> = .init(.init()) fileprivate let state: OSAllocatedUnfairLock<State> = .init(uncheckedState: .init())
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
@@ -170,9 +170,10 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
} }
func handleUpdateResponse(response: UNNotificationResponse) { func handleUpdateResponse(response: UNNotificationResponse) {
let id = response.actionIdentifier
state.withLock { state in state.withLock { state in
guard let update = state.release else { return } guard let update = state.release else { return }
switch response.actionIdentifier { switch id {
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier: case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
NSWorkspace.shared.open(update.html_url) NSWorkspace.shared.open(update.html_url)
case Notifier.Constants.ignoreActionIdentitifier: case Notifier.Constants.ignoreActionIdentitifier:
@@ -184,15 +185,21 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
} }
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async { func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
// let (secret, store, persistOptions, callback): (AnySecret?, AnySecretStore?, TimeInterval?, State.PersistAuthentication?) = state.withLock { state in guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String,
// guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String, let secret = state.pendingPersistableSecrets[secretID], let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
// let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String, let store = state.pendingPersistableStores[storeID] return
// else { return (nil, nil, nil, nil) } }
// state.pendingPersistableSecrets[secretID] = nil let id = response.actionIdentifier
// return (secret, store, state.persistOptions[response.actionIdentifier], state.persistAuthentication)
// } let (secret, store, persistOptions, callback): (AnySecret?, AnySecretStore?, TimeInterval?, State.PersistAuthentication?) = state.withLock { state in
// guard let secret, let store, let persistOptions else { return } guard let secret = state.pendingPersistableSecrets[secretID],
// await callback?(secret, store, persistOptions) let store = state.pendingPersistableStores[storeID]
else { return (nil, nil, nil, nil) }
state.pendingPersistableSecrets[secretID] = nil
return (secret, store, state.persistOptions[id], state.persistAuthentication)
}
guard let secret, let store, let persistOptions else { return }
await callback?(secret, store, persistOptions)
} }

View File

@@ -646,6 +646,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1; MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -675,6 +676,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1; MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -773,6 +775,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1; MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -796,6 +799,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1; MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -821,6 +825,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1; MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -847,6 +852,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1; MARKETING_VERSION = 1;
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -1103,6 +1103,134 @@
} }
} }
}, },
"copyable_click_to_copy_button" : {
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Clica per copiar"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zum Kopieren Anklicken"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Click to Copy"
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Klikkaa kopioidaksesi"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cliquer pour copier"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Clicca per copiare"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "クリックしてコピー"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "복사하기"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Clique para Copiar"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "点击拷贝"
}
}
}
},
"copyable_copied" : {
"localizations" : {
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Copiat"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kopiert"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Copied"
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kopioitu"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Copié"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Copiato"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "コピーしました"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "복사됨"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Copiado"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已拷贝"
}
}
}
},
"create_secret_cancel_button" : { "create_secret_cancel_button" : {
"localizations" : { "localizations" : {
"ca" : { "ca" : {

View File

@@ -1,32 +1,25 @@
import Foundation import Foundation
import Synchronization import os
import Observation import Observation
import Brief import Brief
@Observable class PreviewUpdater: UpdaterProtocol { @Observable class PreviewUpdater: UpdaterProtocol {
var update: Release? { var update: Release? {
_update.withLock { $0 } _update.lockedValue
} }
let _update: Mutex<Release?> = .init(nil) let _update: OSAllocatedUnfairLock<Release?> = .init(uncheckedState: nil)
let testBuild = false let testBuild = false
init(update: Update = .none) { init(update: Update = .none) {
switch update { switch update {
case .none: case .none:
_update.withLock { _update.lockedValue = nil
$0 = nil
}
case .advisory: case .advisory:
_update.withLock { _update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
$0 = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
}
case .critical: case .critical:
_update.withLock { _update.lockedValue = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
$0 = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
}
} }
} }

View File

@@ -8,9 +8,9 @@ struct CopyableView: View {
var text: String var text: String
@State private var interactionState: InteractionState = .normal @State private var interactionState: InteractionState = .normal
@Namespace var namespace @Environment(\.colorScheme) private var colorScheme
var content: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
image image
@@ -22,7 +22,7 @@ struct CopyableView: View {
.foregroundColor(primaryTextColor) .foregroundColor(primaryTextColor)
Spacer() Spacer()
if interactionState != .normal { if interactionState != .normal {
hoverIcon Text(hoverText)
.bold() .bold()
.textCase(.uppercase) .textCase(.uppercase)
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
@@ -39,23 +39,17 @@ struct CopyableView: View {
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
} }
._background(interactionState: interactionState) .background(backgroundColor)
.frame(minWidth: 150, maxWidth: .infinity) .frame(minWidth: 150, maxWidth: .infinity)
} .cornerRadius(10)
var body: some View {
content
.onHover { hovering in .onHover { hovering in
withAnimation { withAnimation {
interactionState = hovering ? .hovering : .normal interactionState = hovering ? .hovering : .normal
} }
} }
.onDrag({ .onDrag {
NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier) NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier)
}, preview: { }
content
._background(interactionState: .dragging)
})
.onTapGesture { .onTapGesture {
copy() copy()
withAnimation { withAnimation {
@@ -72,20 +66,31 @@ struct CopyableView: View {
) )
} }
var hoverIcon: Image { var hoverText: LocalizedStringKey {
switch interactionState { switch interactionState {
case .hovering, .dragging: case .hovering:
return Image(systemName: "document.on.document") return "copyable_click_to_copy_button"
case .clicking: case .clicking:
return Image(systemName: "checkmark.circle.fill") return "copyable_copied"
case .normal: case .normal:
fatalError() fatalError()
} }
} }
var backgroundColor: Color {
switch interactionState {
case .normal:
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
case .hovering:
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
case .clicking:
return .accentColor
}
}
var primaryTextColor: Color { var primaryTextColor: Color {
switch interactionState { switch interactionState {
case .normal, .hovering, .dragging: case .normal, .hovering:
return Color(.textColor) return Color(.textColor)
case .clicking: case .clicking:
return .white return .white
@@ -94,7 +99,7 @@ struct CopyableView: View {
var secondaryTextColor: Color { var secondaryTextColor: Color {
switch interactionState { switch interactionState {
case .normal, .hovering, .dragging: case .normal, .hovering:
return Color(.secondaryLabelColor) return Color(.secondaryLabelColor)
case .clicking: case .clicking:
return .white return .white
@@ -106,59 +111,12 @@ struct CopyableView: View {
NSPasteboard.general.setString(text, forType: .string) NSPasteboard.general.setString(text, forType: .string)
} }
} private enum InteractionState {
case normal, hovering, clicking
fileprivate enum InteractionState {
case normal, hovering, clicking, dragging
}
extension View {
fileprivate func _background(interactionState: InteractionState) -> some View {
modifier(BackgroundViewModifier(interactionState: interactionState))
} }
} }
fileprivate struct BackgroundViewModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
let interactionState: InteractionState
func body(content: Content) -> some View {
if interactionState == .dragging {
content
.background(backgroundColor(interactionState: interactionState), in: RoundedRectangle(cornerRadius: 15))
} else {
if #available(macOS 26.0, *) {
content
// Very thin opacity lets user hover anywhere over the view, glassEffect doesn't allow.
.background(.white.opacity(0.01), in: RoundedRectangle(cornerRadius: 15))
.glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: 15))
} else {
content
.background(backgroundColor(interactionState: interactionState))
.cornerRadius(10)
}
}
}
func backgroundColor(interactionState: InteractionState) -> Color {
switch interactionState {
case .normal:
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
case .hovering, .dragging:
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
case .clicking:
return .accentColor
}
}
}
#if DEBUG #if DEBUG
struct CopyableView_Previews: PreviewProvider { struct CopyableView_Previews: PreviewProvider {

View File

@@ -2,6 +2,124 @@ import SwiftUI
struct SetupView: View { struct SetupView: View {
@Binding var visible: Bool
@Binding var setupComplete: Bool
@State var installed = false
@State var updates = false
@State var sshConfig = false
var body: some View {
VStack(spacing: 0) {
NewStepView(title: "setup_agent_title", description: "setup_agent_description") {
OnboardingButton("setup_agent_install_button", installed) {
Task {
await LaunchAgentController().install()
installed = true
}
}
}
Divider()
NewStepView(title: "setup_updates_title", description: "setup_updates_description") {
OnboardingButton("setup_updates_ok", false) {
Task {
updates = true
}
}
}
Divider()
NewStepView(title: "setup_ssh_title", description: "setup_ssh_description") {
HStack {
OnboardingButton("setup_ssh_added_manually_button", false) {
sshConfig = true
}
OnboardingButton("Add Automatically", false) {
// let controller = ShellConfigurationController()
// if controller.addToShell(shellInstructions: selectedShellInstruction) {
// }
sshConfig = true
}
}
}
}
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.frame(minWidth: 500, idealWidth: 500, minHeight: 500, idealHeight: 500)
.padding()
}
}
struct OnboardingButton: View {
let label: LocalizedStringResource
let complete: Bool
let action: () -> Void
init(_ label: LocalizedStringResource, _ complete: Bool, action: @escaping () -> Void) {
self.label = label
self.complete = complete
self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Text(label)
if complete {
Image(systemName: "checkmark.circle.fill")
}
}
.padding(.vertical, 2)
}
.disabled(complete)
.styled
}
}
extension View {
@ViewBuilder
var styled: some View {
if #available(macOS 26.0, *) {
buttonStyle(.glassProminent)
} else {
buttonStyle(.borderedProminent)
}
}
}
struct NewStepView<Content: View>: View {
let title: LocalizedStringResource
let description: LocalizedStringResource
let actions: Content
init(title: LocalizedStringResource, description: LocalizedStringResource, actions: () -> Content) {
self.title = title
self.description = description
self.actions = actions()
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.bold()
Text(description)
}
Spacer(minLength: 20)
actions
}
.padding(20)
}
}
struct OldSetupView: View {
@State var stepIndex = 0 @State var stepIndex = 0
@Binding var visible: Bool @Binding var visible: Bool
@Binding var setupComplete: Bool @Binding var setupComplete: Bool