Compare commits
22 Commits
stripenc
...
xcode_26_t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a351d75e | ||
|
|
53a23b265a | ||
|
|
e0c2775971 | ||
|
|
dc714f9b38 | ||
|
|
7413d78558 | ||
|
|
163d38c12e | ||
|
|
f9e512e6c6 | ||
|
|
f8de78210b | ||
|
|
81f5b41d6a | ||
|
|
998f4b9bf4 | ||
|
|
bab76da2ab | ||
|
|
9b02afb20c | ||
|
|
576e625b8f | ||
|
|
304741e019 | ||
|
|
8e707545d1 | ||
|
|
e332b7cb9d | ||
|
|
c09ad3ecc1 | ||
|
|
28a4dafad4 | ||
|
|
c2563be404 | ||
|
|
970e407e29 | ||
|
|
2dc317d398 | ||
|
|
8ea8f0510c |
BIN
.github/readme/app-dark.png
vendored
|
Before Width: | Height: | Size: 520 KiB After Width: | Height: | Size: 572 KiB |
BIN
.github/readme/app-light.png
vendored
|
Before Width: | Height: | Size: 519 KiB After Width: | Height: | Size: 545 KiB |
BIN
.github/readme/notification.png
vendored
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.0 MiB |
2
.github/workflows/nightly.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
run: ./.github/scripts/signing.sh
|
||||
- name: Set Environment
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta_5.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
|
||||
- name: Update Build Number
|
||||
env:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
run: ./.github/scripts/signing.sh
|
||||
- name: Set Environment
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta_5.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
|
||||
- name: Test
|
||||
run: |
|
||||
pushd Sources/Packages
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
run: ./.github/scripts/signing.sh
|
||||
- name: Set Environment
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta_5.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
|
||||
- name: Update Build Number
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref }}
|
||||
|
||||
2
.github/workflows/test.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set Environment
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta_5.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26_beta.app
|
||||
- name: Test
|
||||
run: |
|
||||
pushd Sources/Packages
|
||||
|
||||
@@ -110,15 +110,6 @@ Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
|
||||
|
||||
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
|
||||
|
||||
## Retcon
|
||||
|
||||
Add this to your `~/.ssh/config` (the path should match the socket path from the setup flow).
|
||||
|
||||
```
|
||||
Host *
|
||||
IdentityAgent /Users/$YOUR_USERNAME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
|
||||
```
|
||||
|
||||
# The app I use isn't listed here!
|
||||
|
||||
If you know how to get it set up, please open a PR for this page and add it! Contributions are very welcome.
|
||||
|
||||
@@ -20,7 +20,7 @@ If you'd like to contribute a translation, please see [Localizing](LOCALIZING.md
|
||||
|
||||
## Credits
|
||||
|
||||
If you make a material contribution to the app, please add yourself to the end of the [credits](https://github.com/maxgoedjen/secretive/blob/main/Sources/Secretive/Credits.rtf).
|
||||
If you make a material contribution to the app, please add yourself to the end of the [credits](https://github.com/maxgoedjen/secretive/blob/main/Secretive/Credits.rtf).
|
||||
|
||||
## Collaborator Status
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 69 KiB |
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"fill" : {
|
||||
"solid" : "srgb:0.00000,0.53333,1.00000,0.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blur-material" : 0.5,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "Icon 7.png",
|
||||
"name" : "Signature",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
64.00083178971097,
|
||||
-58.21801551632592
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"image-name" : "Rectangle Copy 10.png",
|
||||
"name" : "Border"
|
||||
},
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"solid" : "display-p3:0.00000,0.00000,0.00000,0.50000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"image-name" : "Rectangle 2 8.png",
|
||||
"name" : "Backing",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"squares" : [
|
||||
"macOS"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version:6.2
|
||||
// swift-tools-version:6.1
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
@@ -6,7 +6,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "SecretivePackages",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
.macOS(.v15)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
@@ -78,6 +78,6 @@ let package = Package(
|
||||
var swiftSettings: [PackageDescription.SwiftSetting] {
|
||||
[
|
||||
.swiftLanguageMode(.v6),
|
||||
.treatAllWarnings(as: .error),
|
||||
.unsafeFlags(["-warnings-as-errors"])
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import Synchronization
|
||||
|
||||
/// A concrete implementation of ``UpdaterProtocol`` which considers the current release and OS version.
|
||||
@Observable public final class Updater: UpdaterProtocol, Sendable {
|
||||
@Observable public final class Updater: UpdaterProtocol, ObservableObject, Sendable {
|
||||
|
||||
private let state = State()
|
||||
@MainActor @Observable public final class State {
|
||||
var update: Release? = nil
|
||||
nonisolated init() {}
|
||||
}
|
||||
public var update: Release? {
|
||||
state.update
|
||||
_update.withLock { $0 }
|
||||
}
|
||||
|
||||
private let _update: Mutex<Release?> = .init(nil)
|
||||
public let testBuild: Bool
|
||||
|
||||
/// The current OS version.
|
||||
@@ -26,12 +22,7 @@ import Observation
|
||||
/// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
|
||||
/// - osVersion: The current OS version.
|
||||
/// - currentVersion: The current version of the app that is running.
|
||||
public init(
|
||||
checkOnLaunch: Bool,
|
||||
checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value,
|
||||
osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion),
|
||||
currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")
|
||||
) {
|
||||
public init(checkOnLaunch: Bool, checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value, osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion), currentVersion: SemVer = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0")) {
|
||||
self.osVersion = osVersion
|
||||
self.currentVersion = currentVersion
|
||||
testBuild = currentVersion == SemVer("0.0.0")
|
||||
@@ -62,7 +53,9 @@ import Observation
|
||||
guard !release.critical else { return }
|
||||
defaults.set(true, forKey: release.name)
|
||||
await MainActor.run {
|
||||
state.update = nil
|
||||
_update.withLock { value in
|
||||
value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +76,9 @@ extension Updater {
|
||||
let latestVersion = SemVer(release.name)
|
||||
if latestVersion > currentVersion {
|
||||
await MainActor.run {
|
||||
state.update = release
|
||||
_update.withLock { value in
|
||||
value = release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
/// A protocol for retreiving the latest available version of an app.
|
||||
public protocol UpdaterProtocol: Observable, Sendable {
|
||||
public protocol UpdaterProtocol: Observable {
|
||||
|
||||
/// The latest update
|
||||
@MainActor var update: Release? { get }
|
||||
var update: Release? { get }
|
||||
/// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build)
|
||||
var testBuild: Bool { get }
|
||||
|
||||
func ignore(release: Release) async
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@ public final class Agent: Sendable {
|
||||
logger.debug("Agent is running")
|
||||
self.storeList = storeList
|
||||
self.witness = witness
|
||||
Task { @MainActor in
|
||||
await certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
||||
}
|
||||
certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -62,7 +60,7 @@ extension Agent {
|
||||
switch requestType {
|
||||
case .requestIdentities:
|
||||
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
|
||||
response.append(await identities())
|
||||
response.append(identities())
|
||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
||||
case .signRequest:
|
||||
let provenance = requestTracer.provenance(from: reader)
|
||||
@@ -85,9 +83,9 @@ extension Agent {
|
||||
|
||||
/// Lists the identities available for signing operations
|
||||
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
||||
func identities() async -> Data {
|
||||
let secrets = await storeList.allSecrets
|
||||
await certificateHandler.reloadCertificates(for: secrets)
|
||||
func identities() -> Data {
|
||||
let secrets = storeList.allSecrets
|
||||
certificateHandler.reloadCertificates(for: secrets)
|
||||
var count = secrets.count
|
||||
var keyData = Data()
|
||||
|
||||
@@ -97,7 +95,7 @@ extension Agent {
|
||||
keyData.append(writer.lengthAndData(of: keyBlob))
|
||||
keyData.append(writer.lengthAndData(of: curveData))
|
||||
|
||||
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
||||
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) {
|
||||
keyData.append(writer.lengthAndData(of: certificateData))
|
||||
keyData.append(writer.lengthAndData(of: name))
|
||||
count += 1
|
||||
@@ -119,13 +117,13 @@ extension Agent {
|
||||
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 certificatePublicKey = await certificateHandler.publicKeyHash(from: payloadHash) {
|
||||
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
|
||||
hash = certificatePublicKey
|
||||
} else {
|
||||
hash = payloadHash
|
||||
}
|
||||
|
||||
guard let (store, secret) = await secret(matching: hash) else {
|
||||
guard let (store, secret) = secret(matching: hash) else {
|
||||
logger.debug("Agent did not have a key matching \(hash as NSData)")
|
||||
throw AgentError.noMatchingKey
|
||||
}
|
||||
@@ -191,10 +189,9 @@ extension Agent {
|
||||
|
||||
/// Gives any store with no loaded secrets a chance to reload.
|
||||
func reloadSecretsIfNeccessary() async {
|
||||
for store in await storeList.stores {
|
||||
if await store.secrets.isEmpty {
|
||||
let name = await store.name
|
||||
logger.debug("Store \(name, privacy: .public) has no loaded secrets. Reloading.")
|
||||
for store in storeList.stores {
|
||||
if store.secrets.isEmpty {
|
||||
logger.debug("Store \(store.name, privacy: .public) has no loaded secrets. Reloading.")
|
||||
await store.reloadSecrets()
|
||||
}
|
||||
}
|
||||
@@ -203,16 +200,16 @@ extension Agent {
|
||||
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
|
||||
/// - Parameter hash: The hash to match against.
|
||||
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.
|
||||
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
|
||||
for store in await storeList.stores {
|
||||
let allMatching = await store.secrets.filter { secret in
|
||||
func secret(matching hash: Data) -> (AnySecretStore, AnySecret)? {
|
||||
storeList.stores.compactMap { store -> (AnySecretStore, AnySecret)? in
|
||||
let allMatching = store.secrets.filter { secret in
|
||||
hash == writer.data(secret: secret)
|
||||
}
|
||||
if let matching = allMatching.first {
|
||||
return (store, matching)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return nil
|
||||
}.first
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import Combine
|
||||
/// Type eraser for SecretStore.
|
||||
public class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
|
||||
let base: any Sendable
|
||||
private let _isAvailable: @MainActor @Sendable () -> Bool
|
||||
let base: Any
|
||||
private let _isAvailable: @Sendable () -> Bool
|
||||
private let _id: @Sendable () -> UUID
|
||||
private let _name: @MainActor @Sendable () -> String
|
||||
private let _secrets: @MainActor @Sendable () -> [AnySecret]
|
||||
private let _name: @Sendable () -> String
|
||||
private let _secrets: @Sendable () -> [AnySecret]
|
||||
private let _sign: @Sendable (Data, AnySecret, SigningRequestProvenance) async throws -> Data
|
||||
private let _verify: @Sendable (Data, Data, AnySecret) async throws -> Bool
|
||||
private let _existingPersistedAuthenticationContext: @Sendable (AnySecret) async -> PersistedAuthenticationContext?
|
||||
@@ -28,7 +28,7 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
_reloadSecrets = { await secretStore.reloadSecrets() }
|
||||
}
|
||||
|
||||
@MainActor public var isAvailable: Bool {
|
||||
public var isAvailable: Bool {
|
||||
return _isAvailable()
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ public class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||
return _id()
|
||||
}
|
||||
|
||||
@MainActor public var name: String {
|
||||
public var name: String {
|
||||
return _name()
|
||||
}
|
||||
|
||||
@MainActor public var secrets: [AnySecret] {
|
||||
public var secrets: [AnySecret] {
|
||||
return _secrets()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Synchronization
|
||||
|
||||
/// Manages storage and lookup for OpenSSH certificates.
|
||||
public actor OpenSSHCertificateHandler: Sendable {
|
||||
public final class OpenSSHCertificateHandler: Sendable {
|
||||
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
||||
private let writer = OpenSSHKeyWriter()
|
||||
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
||||
private let keyBlobsAndNames: Mutex<[AnySecret: (Data, Data)]> = .init([:])
|
||||
|
||||
/// Initializes an OpenSSHCertificateHandler.
|
||||
public init() {
|
||||
@@ -20,11 +21,24 @@ public actor OpenSSHCertificateHandler: Sendable {
|
||||
logger.log("No certificates, short circuiting")
|
||||
return
|
||||
}
|
||||
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
|
||||
partialResult[next] = try? loadKeyblobAndName(for: next)
|
||||
keyBlobsAndNames.withLock {
|
||||
$0 = 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.withLock {
|
||||
$0[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.
|
||||
@@ -53,7 +67,9 @@ public actor OpenSSHCertificateHandler: Sendable {
|
||||
/// - 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)]
|
||||
keyBlobsAndNames.withLock {
|
||||
$0[AnySecret(secret)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
|
||||
|
||||
@@ -1,39 +1,55 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import Synchronization
|
||||
|
||||
/// A "Store Store," which holds a list of type-erased stores.
|
||||
@Observable @MainActor public final class SecretStoreList: Sendable {
|
||||
@Observable public final class SecretStoreList: Sendable {
|
||||
|
||||
/// The Stores managed by the SecretStoreList.
|
||||
public var stores: [AnySecretStore] = []
|
||||
public var stores: [AnySecretStore] {
|
||||
__stores.withLock { $0 }
|
||||
}
|
||||
private let __stores: Mutex<[AnySecretStore]> = .init([])
|
||||
|
||||
/// A modifiable store, if one is available.
|
||||
public var modifiableStore: AnySecretStoreModifiable? = nil
|
||||
public var modifiableStore: AnySecretStoreModifiable? {
|
||||
__modifiableStore.withLock { $0 }
|
||||
}
|
||||
private let __modifiableStore: Mutex<AnySecretStoreModifiable?> = .init(nil)
|
||||
|
||||
/// Initializes a SecretStoreList.
|
||||
public nonisolated init() {
|
||||
public init() {
|
||||
}
|
||||
|
||||
/// Adds a non-type-erased SecretStore to the list.
|
||||
public func add<SecretStoreType: SecretStore>(store: SecretStoreType) {
|
||||
stores.append(AnySecretStore(store))
|
||||
__stores.withLock {
|
||||
$0.append(AnySecretStore(store))
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a non-type-erased modifiable SecretStore.
|
||||
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
|
||||
let modifiable = AnySecretStoreModifiable(modifiable: store)
|
||||
if modifiableStore == nil {
|
||||
modifiableStore = modifiable
|
||||
__modifiableStore.withLock {
|
||||
$0 = modifiable
|
||||
}
|
||||
__stores.withLock {
|
||||
$0.append(modifiable)
|
||||
}
|
||||
stores.append(modifiable)
|
||||
}
|
||||
|
||||
/// A boolean describing whether there are any Stores available.
|
||||
public var anyAvailable: Bool {
|
||||
stores.contains(where: \.isAvailable)
|
||||
__stores.withLock {
|
||||
$0.reduce(false, { $0 || $1.isAvailable })
|
||||
}
|
||||
}
|
||||
|
||||
public var allSecrets: [AnySecret] {
|
||||
stores.flatMap(\.secrets)
|
||||
__stores.withLock {
|
||||
$0.flatMap(\.secrets)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ public protocol SecretStore: Identifiable, Sendable {
|
||||
associatedtype SecretType: Secret
|
||||
|
||||
/// A boolean indicating whether or not the store is available.
|
||||
@MainActor var isAvailable: Bool { get }
|
||||
var isAvailable: Bool { get }
|
||||
/// A unique identifier for the store.
|
||||
var id: UUID { get }
|
||||
/// A user-facing name for the store.
|
||||
@MainActor var name: String { get }
|
||||
var name: String { get }
|
||||
/// The secrets the store manages.
|
||||
@MainActor var secrets: [SecretType] { get }
|
||||
var secrets: [SecretType] { get }
|
||||
|
||||
/// Signs a data payload with a specified Secret.
|
||||
/// - Parameters:
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Describes the chain of applications that requested a signature operation.
|
||||
public struct SigningRequestProvenance: Equatable, Sendable {
|
||||
public struct SigningRequestProvenance: Equatable {
|
||||
|
||||
/// 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`
|
||||
@@ -30,7 +30,7 @@ extension SigningRequestProvenance {
|
||||
extension SigningRequestProvenance {
|
||||
|
||||
/// Describes a process in a `SigningRequestProvenance` chain.
|
||||
public struct Process: Equatable, Sendable {
|
||||
public struct Process: Equatable {
|
||||
|
||||
/// The pid of the process.
|
||||
public let pid: Int32
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import LocalAuthentication
|
||||
import SecretKit
|
||||
|
||||
extension SecureEnclave {
|
||||
|
||||
/// A context describing a persisted authentication.
|
||||
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
|
||||
|
||||
/// The Secret to persist authentication for.
|
||||
let secret: Secret
|
||||
/// The LAContext used to authorize the persistent context.
|
||||
nonisolated(unsafe) let context: LAContext
|
||||
/// An expiration date for the context.
|
||||
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
||||
let monotonicExpiration: UInt64
|
||||
|
||||
/// Initializes a context.
|
||||
/// - Parameters:
|
||||
/// - secret: The Secret to persist authentication for.
|
||||
/// - context: The LAContext used to authorize the persistent context.
|
||||
/// - duration: The duration of the authorization context, in seconds.
|
||||
init(secret: Secret, context: LAContext, duration: TimeInterval) {
|
||||
self.secret = secret
|
||||
self.context = context
|
||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
||||
}
|
||||
|
||||
/// A boolean describing whether or not the context is still valid.
|
||||
var valid: Bool {
|
||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||
}
|
||||
|
||||
var expiration: Date {
|
||||
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
||||
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
||||
return Date(timeIntervalSinceNow: remainingInSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
actor PersistentAuthenticationHandler: Sendable {
|
||||
|
||||
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
|
||||
|
||||
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
|
||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
||||
return persisted
|
||||
}
|
||||
|
||||
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||
let newContext = LAContext()
|
||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .spellOut
|
||||
formatter.allowedUnits = [.hour, .minute, .day]
|
||||
|
||||
if let durationString = formatter.string(from: duration) {
|
||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
|
||||
} else {
|
||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
|
||||
}
|
||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||
guard success else { return }
|
||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||
persistedAuthenticationContexts[secret] = context
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,30 +2,35 @@ import Foundation
|
||||
import Observation
|
||||
import Security
|
||||
import CryptoKit
|
||||
import LocalAuthentication
|
||||
@preconcurrency import LocalAuthentication
|
||||
import SecretKit
|
||||
import Synchronization
|
||||
|
||||
extension SecureEnclave {
|
||||
|
||||
/// An implementation of Store backed by the Secure Enclave.
|
||||
@Observable public final class Store: SecretStoreModifiable {
|
||||
|
||||
@MainActor public var secrets: [Secret] = []
|
||||
public var isAvailable: Bool {
|
||||
CryptoKit.SecureEnclave.isAvailable
|
||||
}
|
||||
public let id = UUID()
|
||||
public let name = String(localized: "secure_enclave")
|
||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
|
||||
public var secrets: [Secret] {
|
||||
_secrets.withLock { $0 }
|
||||
}
|
||||
private let _secrets: Mutex<[Secret]> = .init([])
|
||||
|
||||
private let persistedAuthenticationContexts: Mutex<[Secret: PersistentAuthenticationContext]> = .init([:])
|
||||
|
||||
/// Initializes a Store.
|
||||
@MainActor public init() {
|
||||
loadSecrets()
|
||||
public init() {
|
||||
Task {
|
||||
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
||||
await reloadSecretsInternal(notifyAgent: false)
|
||||
}
|
||||
}
|
||||
loadSecrets()
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
@@ -99,41 +104,43 @@ extension SecureEnclave {
|
||||
await reloadSecretsInternal()
|
||||
}
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||
let context: LAContext
|
||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
||||
context = existing.context
|
||||
} else {
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
||||
let context: Mutex<LAContext>
|
||||
// if let existing = persistedAuthenticationContexts.withLock({ $0 })[secret], existing.valid {
|
||||
// context = existing.context
|
||||
// } else {
|
||||
let newContext = LAContext()
|
||||
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||
context = newContext
|
||||
}
|
||||
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?
|
||||
context = .init(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)
|
||||
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
|
||||
throw SigningError(error: signError)
|
||||
}
|
||||
return signature as Data
|
||||
}
|
||||
return signature as Data
|
||||
}
|
||||
|
||||
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
|
||||
@@ -171,12 +178,32 @@ extension SecureEnclave {
|
||||
return verified
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
||||
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
||||
guard let persisted = persistedAuthenticationContexts.withLock({ $0 })[secret], persisted.valid else { return nil }
|
||||
return persisted
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) throws {
|
||||
let newContext = LAContext()
|
||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .spellOut
|
||||
formatter.allowedUnits = [.hour, .minute, .day]
|
||||
|
||||
if let durationString = formatter.string(from: duration) {
|
||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_\(secret.name)_\(durationString)")
|
||||
} else {
|
||||
newContext.localizedReason = String(localized: "auth_context_persist_for_duration_unknown_\(secret.name)")
|
||||
}
|
||||
newContext.evaluatePolicy(LAPolicy.deviceOwnerAuthentication, localizedReason: newContext.localizedReason) { [weak self] success, _ in
|
||||
guard success, let self else { return }
|
||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||
self.persistedAuthenticationContexts.withLock {
|
||||
$0[secret] = context
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func reloadSecrets() async {
|
||||
@@ -191,9 +218,11 @@ extension SecureEnclave.Store {
|
||||
|
||||
/// Reloads all secrets from the store.
|
||||
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
|
||||
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
|
||||
private func reloadSecretsInternal(notifyAgent: Bool = true) async {
|
||||
let before = secrets
|
||||
secrets.removeAll()
|
||||
_secrets.withLock {
|
||||
$0.removeAll()
|
||||
}
|
||||
loadSecrets()
|
||||
if secrets != before {
|
||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||
@@ -204,7 +233,7 @@ extension SecureEnclave.Store {
|
||||
}
|
||||
|
||||
/// Loads all secrets from the store.
|
||||
@MainActor private func loadSecrets() {
|
||||
private func loadSecrets() {
|
||||
let publicAttributes = KeychainDictionary([
|
||||
kSecClass: kSecClassKey,
|
||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
||||
@@ -255,7 +284,9 @@ extension SecureEnclave.Store {
|
||||
}
|
||||
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
|
||||
}
|
||||
secrets.append(contentsOf: wrapped)
|
||||
_secrets.withLock {
|
||||
$0.append(contentsOf: wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves a public key.
|
||||
@@ -290,3 +321,42 @@ extension SecureEnclave {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SecureEnclave {
|
||||
|
||||
/// A context describing a persisted authentication.
|
||||
private final class PersistentAuthenticationContext: PersistedAuthenticationContext {
|
||||
|
||||
/// The Secret to persist authentication for.
|
||||
let secret: Secret
|
||||
/// The LAContext used to authorize the persistent context.
|
||||
nonisolated(unsafe) let context: LAContext
|
||||
/// An expiration date for the context.
|
||||
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
||||
let monotonicExpiration: UInt64
|
||||
|
||||
/// Initializes a context.
|
||||
/// - Parameters:
|
||||
/// - secret: The Secret to persist authentication for.
|
||||
/// - context: The LAContext used to authorize the persistent context.
|
||||
/// - duration: The duration of the authorization context, in seconds.
|
||||
init(secret: Secret, context: LAContext, duration: TimeInterval) {
|
||||
self.secret = secret
|
||||
self.context = context
|
||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
||||
}
|
||||
|
||||
/// A boolean describing whether or not the context is still valid.
|
||||
var valid: Bool {
|
||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||
}
|
||||
|
||||
var expiration: Date {
|
||||
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
||||
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
||||
return Date(timeIntervalSinceNow: remainingInSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
import Observation
|
||||
import Security
|
||||
import CryptoTokenKit
|
||||
@@ -7,39 +8,37 @@ import SecretKit
|
||||
|
||||
extension SmartCard {
|
||||
|
||||
@MainActor @Observable fileprivate final class State {
|
||||
private struct State {
|
||||
var isAvailable = false
|
||||
var name = String(localized: "smart_card")
|
||||
var secrets: [Secret] = []
|
||||
let watcher = TKTokenWatcher()
|
||||
var tokenID: String? = nil
|
||||
nonisolated init() {}
|
||||
}
|
||||
|
||||
/// An implementation of Store backed by a Smart Card.
|
||||
@Observable public final class Store: SecretStore {
|
||||
|
||||
private let state = State()
|
||||
private let state: Mutex<State> = .init(.init())
|
||||
public var isAvailable: Bool {
|
||||
state.isAvailable
|
||||
state.withLock { $0.isAvailable }
|
||||
}
|
||||
|
||||
public let id = UUID()
|
||||
@MainActor public var name: String {
|
||||
state.name
|
||||
public var name: String {
|
||||
state.withLock { $0.name }
|
||||
}
|
||||
public var secrets: [Secret] {
|
||||
state.secrets
|
||||
state.withLock { $0.secrets }
|
||||
}
|
||||
|
||||
/// Initializes a Store.
|
||||
public init() {
|
||||
Task { @MainActor in
|
||||
state.withLock { state in
|
||||
if let tokenID = state.tokenID {
|
||||
state.isAvailable = true
|
||||
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: tokenID)
|
||||
}
|
||||
loadSecrets()
|
||||
state.watcher.setInsertionHandler { id in
|
||||
// Setting insertion handler will cause it to be called immediately.
|
||||
// Make a thread jump so we don't hit a recursive lock attempt.
|
||||
@@ -48,6 +47,7 @@ extension SmartCard {
|
||||
}
|
||||
}
|
||||
}
|
||||
loadSecrets()
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
@@ -60,8 +60,8 @@ extension SmartCard {
|
||||
fatalError("Keys must be deleted on the smart card.")
|
||||
}
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||
guard let tokenID = await state.tokenID else { fatalError() }
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
||||
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() }
|
||||
let context = LAContext()
|
||||
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
|
||||
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||
@@ -120,7 +120,7 @@ extension SmartCard {
|
||||
}
|
||||
|
||||
/// Reloads all secrets from the store.
|
||||
@MainActor public func reloadSecrets() {
|
||||
public func reloadSecrets() {
|
||||
reloadSecretsInternal()
|
||||
}
|
||||
|
||||
@@ -130,11 +130,14 @@ extension SmartCard {
|
||||
|
||||
extension SmartCard.Store {
|
||||
|
||||
@MainActor private func reloadSecretsInternal() {
|
||||
let before = state.secrets
|
||||
state.isAvailable = state.tokenID != nil
|
||||
state.secrets.removeAll()
|
||||
loadSecrets()
|
||||
private func reloadSecretsInternal() {
|
||||
let before = state.withLock {
|
||||
$0.isAvailable = $0.tokenID != nil
|
||||
let before = $0.secrets
|
||||
$0.secrets.removeAll()
|
||||
return before
|
||||
}
|
||||
self.loadSecrets()
|
||||
if self.secrets != before {
|
||||
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
|
||||
}
|
||||
@@ -142,31 +145,37 @@ extension SmartCard.Store {
|
||||
|
||||
/// Resets the token ID and reloads secrets.
|
||||
/// - Parameter tokenID: The ID of the token that was inserted.
|
||||
@MainActor private func smartcardInserted(for tokenID: String? = nil) {
|
||||
private func smartcardInserted(for tokenID: String? = nil) {
|
||||
state.withLock { state in
|
||||
guard let string = state.watcher.nonSecureEnclaveTokens.first else { return }
|
||||
guard state.tokenID == nil else { return }
|
||||
guard !string.contains("setoken") else { return }
|
||||
state.tokenID = string
|
||||
state.watcher.addRemovalHandler(self.smartcardRemoved, forTokenID: string)
|
||||
state.tokenID = string
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the token ID and reloads secrets.
|
||||
/// - Parameter tokenID: The ID of the token that was removed.
|
||||
@MainActor private func smartcardRemoved(for tokenID: String? = nil) {
|
||||
state.tokenID = nil
|
||||
private func smartcardRemoved(for tokenID: String? = nil) {
|
||||
state.withLock {
|
||||
$0.tokenID = nil
|
||||
}
|
||||
reloadSecrets()
|
||||
}
|
||||
|
||||
/// Loads all secrets from the store.
|
||||
@MainActor private func loadSecrets() {
|
||||
guard let tokenID = state.tokenID else { return }
|
||||
private func loadSecrets() {
|
||||
guard let tokenID = state.withLock({ $0.tokenID }) else { return }
|
||||
|
||||
let fallbackName = String(localized: "smart_card")
|
||||
if let driverName = state.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
|
||||
state.name = driverName
|
||||
} else {
|
||||
state.name = fallbackName
|
||||
state.withLock {
|
||||
if let driverName = $0.watcher.tokenInfo(forTokenID: tokenID)?.driverName {
|
||||
$0.name = driverName
|
||||
} else {
|
||||
$0.name = fallbackName
|
||||
}
|
||||
}
|
||||
|
||||
let attributes = KeychainDictionary([
|
||||
@@ -190,7 +199,91 @@ extension SmartCard.Store {
|
||||
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
||||
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
|
||||
}
|
||||
state.secrets.append(contentsOf: wrapped)
|
||||
state.withLock {
|
||||
$0.secrets.append(contentsOf: wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: Smart Card specific encryption/decryption/verification
|
||||
extension SmartCard.Store {
|
||||
|
||||
/// Encrypts a payload with a specified key.
|
||||
/// - Parameters:
|
||||
/// - data: The payload to encrypt.
|
||||
/// - secret: The secret to encrypt with.
|
||||
/// - Returns: The encrypted data.
|
||||
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
|
||||
public func encrypt(data: Data, with secret: SecretType) throws -> Data {
|
||||
let context = LAContext()
|
||||
context.localizedReason = String(localized: "auth_context_request_encrypt_description_\(secret.name)")
|
||||
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||
let attributes = KeychainDictionary([
|
||||
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
|
||||
kSecAttrKeySizeInBits: secret.keySize,
|
||||
kSecAttrKeyClass: kSecAttrKeyClassPublic,
|
||||
kSecUseAuthenticationContext: context
|
||||
])
|
||||
var encryptError: SecurityError?
|
||||
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &encryptError)
|
||||
guard let untypedSafe = untyped else {
|
||||
throw KeychainError(statusCode: errSecSuccess)
|
||||
}
|
||||
let key = untypedSafe as! SecKey
|
||||
guard let signature = SecKeyCreateEncryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
|
||||
throw SigningError(error: encryptError)
|
||||
}
|
||||
return signature as Data
|
||||
}
|
||||
|
||||
/// Decrypts a payload with a specified key.
|
||||
/// - Parameters:
|
||||
/// - data: The payload to decrypt.
|
||||
/// - secret: The secret to decrypt with.
|
||||
/// - Returns: The decrypted data.
|
||||
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
|
||||
public func decrypt(data: Data, with secret: SecretType) throws -> Data {
|
||||
guard let tokenID = state.withLock({ $0.tokenID }) else { fatalError() }
|
||||
let context = LAContext()
|
||||
context.localizedReason = String(localized: "auth_context_request_decrypt_description_\(secret.name)")
|
||||
context.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||
let attributes = KeychainDictionary([
|
||||
kSecClass: kSecClassKey,
|
||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||
kSecAttrApplicationLabel: secret.id as CFData,
|
||||
kSecAttrTokenID: tokenID,
|
||||
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 encryptError: SecurityError?
|
||||
guard let signature = SecKeyCreateDecryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
|
||||
throw SigningError(error: encryptError)
|
||||
}
|
||||
return signature as Data
|
||||
}
|
||||
|
||||
private func encryptionAlgorithm(for secret: SecretType) -> SecKeyAlgorithm {
|
||||
switch (secret.algorithm, secret.keySize) {
|
||||
case (.ellipticCurve, 256):
|
||||
return .eciesEncryptionCofactorVariableIVX963SHA256AESGCM
|
||||
case (.ellipticCurve, 384):
|
||||
return .eciesEncryptionCofactorVariableIVX963SHA384AESGCM
|
||||
case (.rsa, 1024), (.rsa, 2048):
|
||||
return .rsaEncryptionOAEPSHA512AESGCM
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Testing
|
||||
import Foundation
|
||||
@testable import Brief
|
||||
|
||||
|
||||
@Suite struct ReleaseParsingTests {
|
||||
|
||||
@Test
|
||||
@@ -59,7 +60,7 @@ import Foundation
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor func greatestSelectedIfOldPatchIsPublishedLater() async throws {
|
||||
func greatestSelectedIfOldPatchIsPublishedLater() async throws {
|
||||
// If 2.x.x series has been published, and a patch for 1.x.x is issued
|
||||
// 2.x.x should still be selected if user can run it.
|
||||
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3"), currentVersion: SemVer("1.0.0"))
|
||||
@@ -77,7 +78,7 @@ import Foundation
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor func latestVersionIsRunnable() async throws {
|
||||
func latestVersionIsRunnable() async throws {
|
||||
// If the 2.x.x series has been published but the user can't run it
|
||||
// the last version the user can run should be selected.
|
||||
let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3"), currentVersion: SemVer("1.0.0"))
|
||||
|
||||
@@ -19,7 +19,7 @@ import CryptoKit
|
||||
|
||||
@Test func identitiesList() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestIdentities)
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestIdentitiesMultiple)
|
||||
@@ -29,7 +29,7 @@ import CryptoKit
|
||||
|
||||
@Test func noMatchingIdentities() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignatureWithNoneMatching)
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
||||
@@ -40,7 +40,7 @@ import CryptoKit
|
||||
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
||||
_ = requestReader.readNextChunk()
|
||||
let dataToSign = requestReader.readNextChunk()
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
let outer = OpenSSHReader(data: stubWriter.data[5...])
|
||||
@@ -62,7 +62,7 @@ import CryptoKit
|
||||
rs.append(s)
|
||||
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
|
||||
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
|
||||
let store = await list.stores.first!
|
||||
let store = list.stores.first!
|
||||
let derVerifies = try await store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
||||
let invalidRandomSignature = try await store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
||||
let invalidRandomData = try await store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret))
|
||||
@@ -78,7 +78,7 @@ import CryptoKit
|
||||
|
||||
@Test func witnessObjectionStopsRequest() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
let witness = StubWitness(speakNow: { _,_ in
|
||||
return true
|
||||
}, witness: { _, _ in })
|
||||
@@ -89,8 +89,8 @@ import CryptoKit
|
||||
|
||||
@Test func witnessSignature() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
nonisolated(unsafe) var witnessed = false
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
var witnessed = false
|
||||
let witness = StubWitness(speakNow: { _, trace in
|
||||
return false
|
||||
}, witness: { _, trace in
|
||||
@@ -103,9 +103,9 @@ import CryptoKit
|
||||
|
||||
@Test func requestTracing() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
nonisolated(unsafe) var speakNowTrace: SigningRequestProvenance?
|
||||
nonisolated(unsafe) var witnessTrace: SigningRequestProvenance?
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret])
|
||||
var speakNowTrace: SigningRequestProvenance! = nil
|
||||
var witnessTrace: SigningRequestProvenance! = nil
|
||||
let witness = StubWitness(speakNow: { _, trace in
|
||||
speakNowTrace = trace
|
||||
return false
|
||||
@@ -115,17 +115,17 @@ import CryptoKit
|
||||
let agent = Agent(storeList: list, witness: witness)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
#expect(witnessTrace == speakNowTrace)
|
||||
#expect(witnessTrace?.origin.displayName == "Finder")
|
||||
#expect(witnessTrace?.origin.validSignature == true)
|
||||
#expect(witnessTrace?.origin.parentPID == 1)
|
||||
#expect(witnessTrace.origin.displayName == "Finder")
|
||||
#expect(witnessTrace.origin.validSignature == true)
|
||||
#expect(witnessTrace.origin.parentPID == 1)
|
||||
}
|
||||
|
||||
// MARK: Exception Handling
|
||||
|
||||
@Test func signatureException() async {
|
||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let store = await list.stores.first?.base as! Stub.Store
|
||||
let list = storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let store = list.stores.first?.base as! Stub.Store
|
||||
store.shouldThrow = true
|
||||
let agent = Agent(storeList: list)
|
||||
await agent.handle(reader: stubReader, writer: stubWriter)
|
||||
@@ -145,7 +145,7 @@ import CryptoKit
|
||||
|
||||
extension AgentTests {
|
||||
|
||||
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
||||
func storeList(with secrets: [Stub.Secret]) -> SecretStoreList {
|
||||
let store = Stub.Store()
|
||||
store.secrets.append(contentsOf: secrets)
|
||||
let storeList = SecretStoreList()
|
||||
|
||||
@@ -3,8 +3,8 @@ import SecretAgentKit
|
||||
|
||||
struct StubWitness {
|
||||
|
||||
let speakNow: @Sendable (AnySecret, SigningRequestProvenance) -> Bool
|
||||
let witness: @Sendable (AnySecret, SigningRequestProvenance) -> ()
|
||||
let speakNow: (AnySecret, SigningRequestProvenance) -> Bool
|
||||
let witness: (AnySecret, SigningRequestProvenance) -> ()
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ import Observation
|
||||
@main
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@MainActor private let storeList: SecretStoreList = {
|
||||
private let storeList: SecretStoreList = {
|
||||
let list = SecretStoreList()
|
||||
list.add(store: SecureEnclave.Store())
|
||||
list.add(store: SmartCard.Store())
|
||||
return list
|
||||
}()
|
||||
private let updater = Updater(checkOnLaunch: true)
|
||||
private let updater = Updater(checkOnLaunch: false)
|
||||
private let notifier = Notifier()
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
||||
private lazy var agent: Agent = {
|
||||
@@ -47,8 +47,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
_ = withObservationTracking {
|
||||
updater.update
|
||||
} onChange: { [updater, notifier] in
|
||||
Task {
|
||||
await notifier.notify(update: updater.update!) { release in
|
||||
notifier.notify(update: updater.update!) { release in
|
||||
Task {
|
||||
await updater.ignore(release: release)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "Mac Icon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "Mac Icon@0.25x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
6
Sources/SecretAgent/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import AppKit
|
||||
import SecretKit
|
||||
import SecretAgentKit
|
||||
import Brief
|
||||
import Synchronization
|
||||
|
||||
final class Notifier: Sendable {
|
||||
|
||||
@@ -29,13 +30,14 @@ final class Notifier: Sendable {
|
||||
formatter.unitsStyle = .spellOut
|
||||
formatter.allowedUnits = [.hour, .minute, .day]
|
||||
|
||||
var identifiers: [String: TimeInterval] = [:]
|
||||
for duration in rawDurations {
|
||||
let seconds = duration.converted(to: .seconds).value
|
||||
guard let string = formatter.string(from: seconds)?.capitalized else { continue }
|
||||
let identifier = Constants.persistAuthenticationCategoryIdentitifier.appending("\(seconds)")
|
||||
let action = UNNotificationAction(identifier: identifier, title: string, options: [])
|
||||
identifiers[identifier] = seconds
|
||||
notificationDelegate.state.withLock { state in
|
||||
state.persistOptions[identifier] = seconds
|
||||
}
|
||||
allPersistenceActions.append(action)
|
||||
}
|
||||
|
||||
@@ -46,8 +48,8 @@ final class Notifier: Sendable {
|
||||
UNUserNotificationCenter.current().setNotificationCategories([updateCategory, criticalUpdateCategory, persistAuthenticationCategory])
|
||||
UNUserNotificationCenter.current().delegate = notificationDelegate
|
||||
|
||||
Task {
|
||||
await notificationDelegate.state.setPersistenceState(options: identifiers) { secret, store, duration in
|
||||
notificationDelegate.state.withLock { state in
|
||||
state.persistAuthentication = { secret, store, duration in
|
||||
guard let duration = duration else { return }
|
||||
try? await store.persistAuthentication(secret: secret, forDuration: duration)
|
||||
}
|
||||
@@ -61,7 +63,10 @@ final class Notifier: Sendable {
|
||||
}
|
||||
|
||||
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) async {
|
||||
await notificationDelegate.state.setPending(secret: secret, store: store)
|
||||
notificationDelegate.state.withLock { state in
|
||||
state.pendingPersistableSecrets[secret.id.description] = secret
|
||||
state.pendingPersistableStores[store.id.description] = store
|
||||
}
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
let notificationContent = UNMutableNotificationContent()
|
||||
notificationContent.title = String(localized: "signed_notification_title_\(provenance.origin.displayName)")
|
||||
@@ -79,8 +84,11 @@ final class Notifier: Sendable {
|
||||
try? await notificationCenter.add(request)
|
||||
}
|
||||
|
||||
func notify(update: Release, ignore: (@Sendable (Release) async -> Void)?) async {
|
||||
await notificationDelegate.state.prepareForNotification(release: update, ignoreAction: ignore)
|
||||
func notify(update: Release, ignore: ((Release) -> Void)?) {
|
||||
notificationDelegate.state.withLock { [update] state in
|
||||
state.release = update
|
||||
// state.ignore = ignore
|
||||
}
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
let notificationContent = UNMutableNotificationContent()
|
||||
if update.critical {
|
||||
@@ -93,7 +101,7 @@ final class Notifier: Sendable {
|
||||
notificationContent.body = update.body
|
||||
notificationContent.categoryIdentifier = update.critical ? Constants.criticalUpdateCategoryIdentitifier : Constants.updateCategoryIdentitifier
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: nil)
|
||||
try? await notificationCenter.add(request)
|
||||
notificationCenter.add(request, withCompletionHandler: nil)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -132,45 +140,18 @@ extension Notifier {
|
||||
|
||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Sendable {
|
||||
|
||||
fileprivate actor State {
|
||||
typealias PersistAction = (@Sendable (AnySecret, AnySecretStore, TimeInterval?) async -> Void)
|
||||
typealias IgnoreAction = (@Sendable (Release) async -> Void)
|
||||
struct State {
|
||||
typealias PersistAuthentication = ((AnySecret, AnySecretStore, TimeInterval?) async -> Void)
|
||||
typealias Ignore = ((Release) -> Void)
|
||||
fileprivate var release: Release?
|
||||
fileprivate var ignoreAction: IgnoreAction?
|
||||
fileprivate var persistAction: PersistAction?
|
||||
fileprivate var ignore: Ignore?
|
||||
fileprivate var persistAuthentication: PersistAuthentication?
|
||||
fileprivate var persistOptions: [String: TimeInterval] = [:]
|
||||
fileprivate var pendingPersistableStores: [String: AnySecretStore] = [:]
|
||||
fileprivate var pendingPersistableSecrets: [String: AnySecret] = [:]
|
||||
|
||||
func setPending(secret: AnySecret, store: AnySecretStore) {
|
||||
pendingPersistableSecrets[secret.id.description] = secret
|
||||
pendingPersistableStores[store.id.description] = store
|
||||
}
|
||||
|
||||
func retrievePending(secretID: String, storeID: String, optionID: String) -> (AnySecret, AnySecretStore, TimeInterval)? {
|
||||
guard let secret = pendingPersistableSecrets[secretID],
|
||||
let store = pendingPersistableStores[storeID],
|
||||
let options = persistOptions[optionID] else {
|
||||
return nil
|
||||
}
|
||||
pendingPersistableSecrets.removeValue(forKey: secretID)
|
||||
return (secret, store, options)
|
||||
}
|
||||
|
||||
func setPersistenceState(options: [String: TimeInterval], action: @escaping PersistAction) {
|
||||
self.persistOptions = options
|
||||
self.persistAction = action
|
||||
}
|
||||
|
||||
func prepareForNotification(release: Release, ignoreAction: IgnoreAction?) {
|
||||
self.release = release
|
||||
self.ignoreAction = ignoreAction
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
fileprivate let state = State()
|
||||
fileprivate let state: Mutex<State> = .init(.init())
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
|
||||
|
||||
@@ -180,7 +161,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
|
||||
let category = response.notification.request.content.categoryIdentifier
|
||||
switch category {
|
||||
case Notifier.Constants.updateCategoryIdentitifier:
|
||||
await handleUpdateResponse(response: response)
|
||||
handleUpdateResponse(response: response)
|
||||
case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
|
||||
await handlePersistAuthenticationResponse(response: response)
|
||||
default:
|
||||
@@ -188,27 +169,30 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateResponse(response: UNNotificationResponse) async {
|
||||
let id = response.actionIdentifier
|
||||
guard let update = await state.release else { return }
|
||||
switch id {
|
||||
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
|
||||
NSWorkspace.shared.open(update.html_url)
|
||||
case Notifier.Constants.ignoreActionIdentitifier:
|
||||
await state.ignoreAction?(update)
|
||||
default:
|
||||
fatalError()
|
||||
func handleUpdateResponse(response: UNNotificationResponse) {
|
||||
state.withLock { state in
|
||||
guard let update = state.release else { return }
|
||||
switch response.actionIdentifier {
|
||||
case Notifier.Constants.updateActionIdentitifier, UNNotificationDefaultActionIdentifier:
|
||||
NSWorkspace.shared.open(update.html_url)
|
||||
case Notifier.Constants.ignoreActionIdentitifier:
|
||||
state.ignore?(update)
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handlePersistAuthenticationResponse(response: UNNotificationResponse) async {
|
||||
guard let secretID = response.notification.request.content.userInfo[Notifier.Constants.persistSecretIDKey] as? String,
|
||||
let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String else {
|
||||
return
|
||||
}
|
||||
let optionID = response.actionIdentifier
|
||||
guard let (secret, store, persistOptions) = await state.retrievePending(secretID: secretID, storeID: storeID, optionID: optionID) else { return }
|
||||
await state.persistAction?(secret, store, persistOptions)
|
||||
// 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, let secret = state.pendingPersistableSecrets[secretID],
|
||||
// let storeID = response.notification.request.content.userInfo[Notifier.Constants.persistStoreIDKey] as? String, let store = state.pendingPersistableStores[storeID]
|
||||
// else { return (nil, nil, nil, nil) }
|
||||
// state.pendingPersistableSecrets[secretID] = nil
|
||||
// return (secret, store, state.persistOptions[response.actionIdentifier], state.persistAuthentication)
|
||||
// }
|
||||
// guard let secret, let store, let persistOptions else { return }
|
||||
// await callback?(secret, store, persistOptions)
|
||||
}
|
||||
|
||||
|
||||
@@ -217,4 +201,3 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, Se
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
5003EF612780081600DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF602780081600DF2006 /* SmartCardSecretKit */; };
|
||||
5003EF632780081B00DF2006 /* SecureEnclaveSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF622780081B00DF2006 /* SecureEnclaveSecretKit */; };
|
||||
5003EF652780081B00DF2006 /* SmartCardSecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF642780081B00DF2006 /* SmartCardSecretKit */; };
|
||||
5008C2402E52792400507AC2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8623FCE48E0099B055 /* Assets.xcassets */; };
|
||||
500B93C32B478D8400E157DE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 500B93C22B478D8400E157DE /* Localizable.xcstrings */; };
|
||||
501421622781262300BBAA70 /* Brief in Frameworks */ = {isa = PBXBuildFile; productRef = 501421612781262300BBAA70 /* Brief */; };
|
||||
501421652781268000BBAA70 /* SecretAgent.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 50A3B78A24026B7500D209EA /* SecretAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
@@ -46,6 +45,7 @@
|
||||
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */ = {isa = PBXBuildFile; fileRef = 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */; };
|
||||
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */; };
|
||||
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; };
|
||||
50A3B79124026B7600D209EA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79024026B7600D209EA /* Assets.xcassets */; };
|
||||
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79324026B7600D209EA /* Preview Assets.xcassets */; };
|
||||
50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; };
|
||||
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
|
||||
@@ -133,6 +133,7 @@
|
||||
5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDirectoryController.swift; sourceTree = "<group>"; };
|
||||
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSecretView.swift; sourceTree = "<group>"; };
|
||||
50A3B78A24026B7500D209EA /* SecretAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SecretAgent.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
50A3B79024026B7600D209EA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
50A3B79324026B7600D209EA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
50A3B79624026B7600D209EA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
50A3B79824026B7600D209EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
@@ -280,6 +281,7 @@
|
||||
children = (
|
||||
50020BAF24064869003D4025 /* AppDelegate.swift */,
|
||||
5018F54E24064786002EB505 /* Notifier.swift */,
|
||||
50A3B79024026B7600D209EA /* Assets.xcassets */,
|
||||
50A3B79524026B7600D209EA /* Main.storyboard */,
|
||||
50A3B79824026B7600D209EA /* Info.plist */,
|
||||
508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */,
|
||||
@@ -384,8 +386,6 @@
|
||||
fi,
|
||||
ko,
|
||||
ca,
|
||||
ru,
|
||||
pl,
|
||||
);
|
||||
mainGroup = 50617D7623FCE48D0099B055;
|
||||
productRefGroup = 50617D8023FCE48E0099B055 /* Products */;
|
||||
@@ -418,8 +418,8 @@
|
||||
50A3B79724026B7600D209EA /* Main.storyboard in Resources */,
|
||||
50E9CF422B51D596004AB36D /* Localizable.xcstrings in Resources */,
|
||||
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */,
|
||||
50A3B79124026B7600D209EA /* Assets.xcassets in Resources */,
|
||||
508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */,
|
||||
5008C2402E52792400507AC2 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -646,7 +646,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -676,7 +675,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -775,7 +773,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -799,7 +796,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -825,7 +821,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -852,7 +847,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -6,33 +6,28 @@ import SmartCardSecretKit
|
||||
import Brief
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
||||
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
|
||||
@Entry var secretStoreList: SecretStoreList = {
|
||||
let list = SecretStoreList()
|
||||
list.add(store: SecureEnclave.Store())
|
||||
list.add(store: SmartCard.Store())
|
||||
return list
|
||||
}()
|
||||
|
||||
private static let _agentStatusChecker = AgentStatusChecker()
|
||||
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = _agentStatusChecker
|
||||
private static let _updater: any UpdaterProtocol = {
|
||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||
return Updater(checkOnLaunch: hasRunSetup)
|
||||
}()
|
||||
@Entry var updater: any UpdaterProtocol = _updater
|
||||
|
||||
@MainActor var secretStoreList: SecretStoreList {
|
||||
EnvironmentValues._secretStoreList
|
||||
}
|
||||
@Entry var agentStatusChecker: any AgentStatusCheckerProtocol = AgentStatusChecker()
|
||||
@Entry var updater: any UpdaterProtocol = Updater(checkOnLaunch: false)
|
||||
}
|
||||
|
||||
@main
|
||||
struct Secretive: App {
|
||||
|
||||
private let storeList: SecretStoreList = {
|
||||
let list = SecretStoreList()
|
||||
list.add(store: SecureEnclave.Store())
|
||||
list.add(store: SmartCard.Store())
|
||||
return list
|
||||
}()
|
||||
private let agentStatusChecker = AgentStatusChecker()
|
||||
private let justUpdatedChecker = JustUpdatedChecker()
|
||||
@Environment(\.agentStatusChecker) var agentStatusChecker
|
||||
|
||||
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
|
||||
@State private var showingSetup = false
|
||||
@State private var showingCreation = false
|
||||
@@ -40,7 +35,9 @@ struct Secretive: App {
|
||||
@SceneBuilder var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
|
||||
.environment(EnvironmentValues._secretStoreList)
|
||||
.environment(storeList)
|
||||
.environment(Updater(checkOnLaunch: hasRunSetup))
|
||||
.environment(agentStatusChecker)
|
||||
.onAppear {
|
||||
if !hasRunSetup {
|
||||
showingSetup = true
|
||||
|
||||
@@ -1,61 +1,53 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-16x16@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-32x32@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-128x128@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-256x256@1x.png",
|
||||
"filename" : "Mac Icon.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-256x256@2x.png",
|
||||
"filename" : "Mac Icon@0.25x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-512x512@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-macOS-ClearDark-1024x1024@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 856 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,17 @@ import AppKit
|
||||
import SecretKit
|
||||
import Observation
|
||||
|
||||
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
|
||||
protocol AgentStatusCheckerProtocol: Observable {
|
||||
var running: Bool { get }
|
||||
var developmentBuild: Bool { get }
|
||||
func check()
|
||||
}
|
||||
|
||||
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
|
||||
@Observable class AgentStatusChecker: AgentStatusCheckerProtocol {
|
||||
|
||||
var running: Bool = false
|
||||
|
||||
nonisolated init() {
|
||||
Task { @MainActor in
|
||||
check()
|
||||
}
|
||||
init() {
|
||||
check()
|
||||
}
|
||||
|
||||
func check() {
|
||||
|
||||
@@ -2,13 +2,13 @@ import Foundation
|
||||
import Combine
|
||||
import AppKit
|
||||
|
||||
protocol JustUpdatedCheckerProtocol: Observable {
|
||||
protocol JustUpdatedCheckerProtocol: ObservableObject {
|
||||
var justUpdated: Bool { get }
|
||||
}
|
||||
|
||||
@Observable class JustUpdatedChecker: JustUpdatedCheckerProtocol {
|
||||
class JustUpdatedChecker: ObservableObject, JustUpdatedCheckerProtocol {
|
||||
|
||||
var justUpdated: Bool = false
|
||||
@Published var justUpdated: Bool = false
|
||||
|
||||
init() {
|
||||
check()
|
||||
|
||||
@@ -10,7 +10,4 @@ class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {
|
||||
self.running = running
|
||||
}
|
||||
|
||||
func check() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ extension Preview {
|
||||
|
||||
extension Preview {
|
||||
|
||||
@Observable final class Store: SecretStore {
|
||||
final class Store: SecretStore, ObservableObject {
|
||||
|
||||
let isAvailable = true
|
||||
let id = UUID()
|
||||
@@ -104,7 +104,7 @@ extension Preview {
|
||||
|
||||
extension Preview {
|
||||
|
||||
@MainActor static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList {
|
||||
static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList {
|
||||
let list = SecretStoreList()
|
||||
for store in stores {
|
||||
list.add(store: store)
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
import Observation
|
||||
import Brief
|
||||
|
||||
@Observable @MainActor final class PreviewUpdater: UpdaterProtocol {
|
||||
@Observable class PreviewUpdater: UpdaterProtocol {
|
||||
|
||||
var update: Release? = nil
|
||||
var update: Release? {
|
||||
_update.withLock { $0 }
|
||||
}
|
||||
let _update: Mutex<Release?> = .init(nil)
|
||||
|
||||
let testBuild = false
|
||||
|
||||
init(update: Update = .none) {
|
||||
switch update {
|
||||
case .none:
|
||||
self.update = nil
|
||||
_update.withLock {
|
||||
$0 = nil
|
||||
}
|
||||
case .advisory:
|
||||
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
|
||||
_update.withLock {
|
||||
$0 = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Some regular update")
|
||||
}
|
||||
case .critical:
|
||||
self.update = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
|
||||
}
|
||||
}
|
||||
_update.withLock {
|
||||
$0 = Release(name: "10.10.10", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Critical Security Update")
|
||||
|
||||
func ignore(release: Release) async {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ struct ContentView: View {
|
||||
@State var activeSecret: AnySecret?
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@Environment(\.secretStoreList) private var storeList
|
||||
@Environment(\.secretStoreList) private var storeList: SecretStoreList
|
||||
@Environment(\.updater) private var updater: any UpdaterProtocol
|
||||
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
|
||||
|
||||
@@ -30,7 +30,7 @@ struct ContentView: View {
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 320)
|
||||
.toolbar {
|
||||
// toolbarItem(updateNoticeView, id: "update")
|
||||
toolbarItem(updateNoticeView, id: "update")
|
||||
toolbarItem(runningOrRunSetupView, id: "setup")
|
||||
toolbarItem(appPathNoticeView, id: "appPath")
|
||||
toolbarItem(newItemView, id: "new")
|
||||
@@ -44,7 +44,6 @@ struct ContentView: View {
|
||||
|
||||
extension ContentView {
|
||||
|
||||
|
||||
@ToolbarContentBuilder
|
||||
func toolbarItem(_ view: some View, id: String) -> some ToolbarContent {
|
||||
if #available(macOS 26.0, *) {
|
||||
|
||||
@@ -8,8 +8,9 @@ struct CopyableView: View {
|
||||
var text: String
|
||||
|
||||
@State private var interactionState: InteractionState = .normal
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var content: some View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
image
|
||||
@@ -21,7 +22,7 @@ struct CopyableView: View {
|
||||
.foregroundColor(primaryTextColor)
|
||||
Spacer()
|
||||
if interactionState != .normal {
|
||||
hoverIcon
|
||||
Text(hoverText)
|
||||
.bold()
|
||||
.textCase(.uppercase)
|
||||
.foregroundColor(secondaryTextColor)
|
||||
@@ -38,23 +39,17 @@ struct CopyableView: View {
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
._background(interactionState: interactionState)
|
||||
.background(backgroundColor)
|
||||
.frame(minWidth: 150, maxWidth: .infinity)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.cornerRadius(10)
|
||||
.onHover { hovering in
|
||||
withAnimation {
|
||||
interactionState = hovering ? .hovering : .normal
|
||||
}
|
||||
}
|
||||
.onDrag({
|
||||
.onDrag {
|
||||
NSItemProvider(item: NSData(data: text.data(using: .utf8)!), typeIdentifier: UTType.utf8PlainText.identifier)
|
||||
}, preview: {
|
||||
content
|
||||
._background(interactionState: .dragging)
|
||||
})
|
||||
}
|
||||
.onTapGesture {
|
||||
copy()
|
||||
withAnimation {
|
||||
@@ -71,23 +66,31 @@ struct CopyableView: View {
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var hoverIcon: some View {
|
||||
var hoverText: LocalizedStringKey {
|
||||
switch interactionState {
|
||||
case .hovering:
|
||||
Image(systemName: "document.on.document")
|
||||
.accessibilityLabel(String(localized: "copyable_click_to_copy_button"))
|
||||
return "copyable_click_to_copy_button"
|
||||
case .clicking:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.accessibilityLabel(String(localized: "copyable_copied"))
|
||||
case .normal, .dragging:
|
||||
EmptyView()
|
||||
return "copyable_copied"
|
||||
case .normal:
|
||||
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 {
|
||||
switch interactionState {
|
||||
case .normal, .hovering, .dragging:
|
||||
case .normal, .hovering:
|
||||
return Color(.textColor)
|
||||
case .clicking:
|
||||
return .white
|
||||
@@ -96,7 +99,7 @@ struct CopyableView: View {
|
||||
|
||||
var secondaryTextColor: Color {
|
||||
switch interactionState {
|
||||
case .normal, .hovering, .dragging:
|
||||
case .normal, .hovering:
|
||||
return Color(.secondaryLabelColor)
|
||||
case .clicking:
|
||||
return .white
|
||||
@@ -108,59 +111,12 @@ struct CopyableView: View {
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileprivate enum InteractionState {
|
||||
case normal, hovering, clicking, dragging
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
fileprivate func _background(interactionState: InteractionState) -> some View {
|
||||
modifier(BackgroundViewModifier(interactionState: interactionState))
|
||||
private enum InteractionState {
|
||||
case normal, hovering, clicking
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
struct CopyableView_Previews: PreviewProvider {
|
||||
|
||||
@@ -112,10 +112,10 @@ extension ThumbnailPickerView {
|
||||
|
||||
}
|
||||
|
||||
@MainActor @Observable class SystemBackground {
|
||||
@MainActor class SystemBackground: ObservableObject {
|
||||
|
||||
static let shared = SystemBackground()
|
||||
var image: NSImage?
|
||||
@Published var image: NSImage?
|
||||
|
||||
private init() {
|
||||
if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) {
|
||||
|
||||
@@ -6,7 +6,7 @@ struct StoreListView: View {
|
||||
|
||||
@Binding var activeSecret: AnySecret?
|
||||
|
||||
@Environment(\.secretStoreList) private var storeList
|
||||
@Environment(SecretStoreList.self) private var storeList: SecretStoreList
|
||||
|
||||
private func secretDeleted(secret: AnySecret) {
|
||||
activeSecret = nextDefaultSecret
|
||||
@@ -22,13 +22,17 @@ struct StoreListView: View {
|
||||
ForEach(storeList.stores) { store in
|
||||
if store.isAvailable {
|
||||
Section(header: Text(store.name)) {
|
||||
ForEach(store.secrets) { secret in
|
||||
SecretListItemView(
|
||||
store: store,
|
||||
secret: secret,
|
||||
deletedSecret: secretDeleted,
|
||||
renamedSecret: secretRenamed
|
||||
)
|
||||
if store.secrets.isEmpty {
|
||||
EmptyStoreView(store: store)
|
||||
} else {
|
||||
ForEach(store.secrets) { secret in
|
||||
SecretListItemView(
|
||||
store: store,
|
||||
secret: secret,
|
||||
deletedSecret: secretDeleted,
|
||||
renamedSecret: secretRenamed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,12 +41,8 @@ struct StoreListView: View {
|
||||
} detail: {
|
||||
if let activeSecret {
|
||||
SecretDetailView(secret: activeSecret)
|
||||
} else if let nextDefaultSecret {
|
||||
// This just means onAppear hasn't executed yet.
|
||||
// Do this to avoid a blip.
|
||||
SecretDetailView(secret: nextDefaultSecret)
|
||||
} else {
|
||||
EmptyStoreView(store: storeList.modifiableStore ?? storeList.stores.first)
|
||||
EmptyStoreView(store: storeList.stores.first)
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
@@ -57,7 +57,7 @@ struct StoreListView: View {
|
||||
extension StoreListView {
|
||||
|
||||
private var nextDefaultSecret: AnySecret? {
|
||||
return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
|
||||
return storeList.stores.compactMap(\.secrets.first).first
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import SwiftUI
|
||||
import Brief
|
||||
|
||||
struct UpdateDetailView: View {
|
||||
struct UpdateDetailView<UpdaterType: Updater>: View {
|
||||
|
||||
@Environment(\.updater) var updater: any UpdaterProtocol
|
||||
@Environment(UpdaterType.self) var updater: UpdaterType
|
||||
|
||||
let update: Release
|
||||
|
||||
|
||||