Compare commits

..

10 Commits

16 changed files with 169 additions and 113 deletions

49
.github/workflows/nightly.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Nightly
on:
schedule:
- cron: "0 8 * * *"
jobs:
build:
runs-on: macos-11.0
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- name: Setup Signing
env:
SIGNING_DATA: ${{ secrets.SIGNING_DATA }}
SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }}
AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }}
APPLE_API_KEY_DATA: ${{ secrets.APPLE_API_KEY_DATA }}
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_13.2.1.app
- name: Update Build Number
env:
RUN_ID: ${{ github.run_id }}
run: |
sed -i '' -e "s/GITHUB_CI_VERSION/0.0.0/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_NUMBER/1.$RUN_ID/g" Sources/Config/Config.xcconfig
sed -i '' -e "s/GITHUB_BUILD_URL/https:\/\/github.com\/maxgoedjen\/secretive\/actions\/runs\/$RUN_ID/g" Sources/Secretive/Credits.rtf
- name: Build
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive
- name: Create ZIPs
run: |
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive/Products/Applications/Secretive.app ./Secretive.zip
ditto -c -k --sequesterRsrc --keepParent Archive.xcarchive ./Archive.zip
- name: Notarize
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: xcrun notarytool submit --key ~/.private_keys/AuthKey_$APPLE_API_KEY_ID.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER Secretive.zip
- name: Document SHAs
run: |
shasum -a 512 Secretive.zip
shasum -a 512 Archive.zip
- name: Upload App to Artifacts
uses: actions/upload-artifact@v1
with:
name: Secretive.zip
path: Secretive.zip

View File

@ -51,6 +51,30 @@ Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
Log out and log in again before launching Cyberduck. Log out and log in again before launching Cyberduck.
## GitKraken
Add this to `~/Library/LaunchAgents/com.maxgoedjen.Secretive.SecretAgent.plist`
```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>link-ssh-auth-sock</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/ln -sf $HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh $SSH_AUTH_SOCK</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
Log out and log in again before launching Gitkraken. Then enable "Use local SSH agent in GitKraken Preferences (Located under Preferences -> SSH)
# The app I use isn't listed here! # The app I use isn't listed here!

4
FAQ.md
View File

@ -40,6 +40,10 @@ Awesome! Just bear in mind that because an app only has access to the keychain i
Secretive checks in with GitHub's releases API to check if there's a new version of Secretive available. You can audit the source code for this feature [here](https://github.com/maxgoedjen/secretive/blob/main/Sources/Packages/Sources/Brief/Updater.swift). Secretive checks in with GitHub's releases API to check if there's a new version of Secretive available. You can audit the source code for this feature [here](https://github.com/maxgoedjen/secretive/blob/main/Sources/Packages/Sources/Brief/Updater.swift).
### How do I uninstall Secretive?
Drag Secretive.app to the trash and remove `~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent`. `SecretAgent` may continue running until you quit it or reboot.
### I have a security issue ### I have a security issue
Please contact [max.goedjen@gmail.com](mailto:max.goedjen@gmail.com) with a subject containing "SECRETIVE SECURITY" immediately with details, and I'll address the issue and credit you ASAP. Please contact [max.goedjen@gmail.com](mailto:max.goedjen@gmail.com) with a subject containing "SECRETIVE SECURITY" immediately with details, and I'll address the issue and credit you ASAP.

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
/// A release is a representation of a downloadable update. /// A release is a representation of a downloadable update.
public struct Release: Codable, Sendable { public struct Release: Codable {
/// The user-facing name of the release. Typically "Secretive 1.2.3" /// The user-facing name of the release. Typically "Secretive 1.2.3"
public let name: String public let name: String
@ -30,9 +30,6 @@ public struct Release: Codable, Sendable {
} }
// TODO: REMOVE WHEN(?) URL GAINS NATIVE CONFORMANCE
extension URL: @unchecked Sendable {}
extension Release: Identifiable { extension Release: Identifiable {
public var id: String { public var id: String {

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
/// A representation of a Semantic Version. /// A representation of a Semantic Version.
public struct SemVer: Sendable { public struct SemVer {
/// The SemVer broken into an array of integers. /// The SemVer broken into an array of integers.
let versionNumbers: [Int] let versionNumbers: [Int]

View File

@ -2,59 +2,53 @@ import Foundation
import Combine import Combine
/// 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.
public actor Updater: ObservableObject, UpdaterProtocol { public class Updater: ObservableObject, UpdaterProtocol {
@MainActor @Published public var update: Release? @Published public var update: Release?
public let testBuild: Bool public let testBuild: Bool
/// The current OS version. /// The current OS version.
private let osVersion: SemVer private let osVersion: SemVer
/// The current version of the app that is running. /// The current version of the app that is running.
private let currentVersion: SemVer private let currentVersion: SemVer
/// The timer responsible for checking for updates regularly.
private var timer: Timer? = nil
/// Initializes an Updater. /// Initializes an Updater.
/// - Parameters: /// - Parameters:
/// - checkOnLaunch: A boolean describing whether the Updater should check for available updates on launch.
/// - checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
/// - osVersion: The current OS version. /// - osVersion: The current OS version.
/// - currentVersion: The current version of the app that is running. /// - currentVersion: The current version of the app that is running.
public init(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.osVersion = osVersion
self.currentVersion = currentVersion self.currentVersion = currentVersion
testBuild = currentVersion == SemVer("0.0.0") testBuild = currentVersion == SemVer("0.0.0")
if checkOnLaunch {
// Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet.
checkForUpdates()
} }
let timer = Timer.scheduledTimer(withTimeInterval: checkFrequency, repeats: true) { _ in
/// Begins checking for updates with the specified frequency. self.checkForUpdates()
/// - Parameter checkFrequency: The interval at which the Updater should check for updates. Subject to a tolerance of 1 hour.
public func beginChecking(checkFrequency: TimeInterval = Measurement(value: 24, unit: UnitDuration.hours).converted(to: .seconds).value) {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: checkFrequency, repeats: true) { _ in
Task {
await self.checkForUpdates()
} }
} timer.tolerance = 60*60
timer?.tolerance = 60*60
}
/// Ends checking for updates.
public func stopChecking() {
timer?.invalidate()
timer = nil
} }
/// Manually trigger an update check. /// Manually trigger an update check.
public func checkForUpdates() async { public func checkForUpdates() {
guard let (data, _) = try? await URLSession.shared.data(from: Constants.updateURL), URLSession.shared.dataTask(with: Constants.updateURL) { data, _, _ in
let releases = try? JSONDecoder().decode([Release].self, from: data) else { return } guard let data = data else { return }
await evaluate(releases: releases) guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return }
self.evaluate(releases: releases)
}.resume()
} }
/// Ignores a specified release. `update` will be nil if the user has ignored the latest available release. /// Ignores a specified release. `update` will be nil if the user has ignored the latest available release.
/// - Parameter release: The release to ignore. /// - Parameter release: The release to ignore.
public func ignore(release: Release) async { public func ignore(release: Release) {
guard !release.critical else { return } guard !release.critical else { return }
defaults.set(true, forKey: release.name) defaults.set(true, forKey: release.name)
await setUpdate(update: update) DispatchQueue.main.async {
self.update = nil
}
} }
} }
@ -63,7 +57,7 @@ extension Updater {
/// Evaluates the available downloadable releases, and selects the newest non-prerelease release that the user is able to run. /// Evaluates the available downloadable releases, and selects the newest non-prerelease release that the user is able to run.
/// - Parameter releases: An array of ``Release`` objects. /// - Parameter releases: An array of ``Release`` objects.
func evaluate(releases: [Release]) async { func evaluate(releases: [Release]) {
guard let release = releases guard let release = releases
.sorted() .sorted()
.reversed() .reversed()
@ -73,12 +67,10 @@ extension Updater {
guard !release.prerelease else { return } guard !release.prerelease else { return }
let latestVersion = SemVer(release.name) let latestVersion = SemVer(release.name)
if latestVersion > currentVersion { if latestVersion > currentVersion {
await setUpdate(update: update) DispatchQueue.main.async {
self.update = release
} }
} }
@MainActor private func setUpdate(update: Release?) {
self.update = update
} }
/// Checks whether the user has ignored a release. /// Checks whether the user has ignored a release.
@ -103,21 +95,3 @@ extension Updater {
} }
} }
@available(macOS, deprecated: 12)
extension URLSession {
// Backport for macOS 11
func data(from url: URL) async throws -> (Data, URLResponse) {
try await withCheckedThrowingContinuation { continuation in
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, let response = response else {
continuation.resume(throwing: error ?? NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: nil))
return
}
continuation.resume(returning: (data, response))
}
}
}
}

View File

@ -4,9 +4,9 @@ import Foundation
public protocol UpdaterProtocol: ObservableObject { public protocol UpdaterProtocol: ObservableObject {
/// The latest update /// 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) /// A boolean describing whether or not the current build of the app is a "test" build (ie, a debug build or otherwise special build)
@MainActor var testBuild: Bool { get } var testBuild: Bool { get }
} }

View File

@ -30,20 +30,23 @@ extension Agent {
/// - Parameters: /// - Parameters:
/// - reader: A ``FileHandleReader`` to read the content of the request. /// - reader: A ``FileHandleReader`` to read the content of the request.
/// - writer: A ``FileHandleWriter`` to write the response to. /// - writer: A ``FileHandleWriter`` to write the response to.
public func handle(reader: FileHandleReader, writer: FileHandleWriter) { /// - Return value:
/// - Boolean if data could be read
public func handle(reader: FileHandleReader, writer: FileHandleWriter) -> Bool {
Logger().debug("Agent handling new data") Logger().debug("Agent handling new data")
let data = Data(reader.availableData) let data = Data(reader.availableData)
guard data.count > 4 else { return } guard data.count > 4 else { return false}
let requestTypeInt = data[4] let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else { guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data)) writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)") Logger().debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return return true
} }
Logger().debug("Agent handling request of type \(requestType.debugDescription)") Logger().debug("Agent handling request of type \(requestType.debugDescription)")
let subData = Data(data[5...]) let subData = Data(data[5...])
let response = handle(requestType: requestType, data: subData, reader: reader) let response = handle(requestType: requestType, data: subData, reader: reader)
writer.write(response) writer.write(response)
return true
} }
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) -> Data { func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) -> Data {

View File

@ -9,7 +9,9 @@ public class SocketController {
/// The active SocketPort. /// The active SocketPort.
private var port: SocketPort? private var port: SocketPort?
/// A handler that will be notified when a new read/write handle is available. /// A handler that will be notified when a new read/write handle is available.
public var handler: ((FileHandleReader, FileHandleWriter) -> Void)? /// False if no data could be read
public var handler: ((FileHandleReader, FileHandleWriter) -> Bool)?
/// Initializes a socket controller with a specified path. /// Initializes a socket controller with a specified path.
/// - Parameter path: The path to use as a socket. /// - Parameter path: The path to use as a socket.
@ -65,7 +67,7 @@ public class SocketController {
@objc func handleConnectionAccept(notification: Notification) { @objc func handleConnectionAccept(notification: Notification) {
Logger().debug("Socket controller accepted connection") Logger().debug("Socket controller accepted connection")
guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return } guard let new = notification.userInfo?[NSFileHandleNotificationFileHandleItem] as? FileHandle else { return }
handler?(new, new) _ = handler?(new, new)
new.waitForDataInBackgroundAndNotify() new.waitForDataInBackgroundAndNotify()
fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.current.currentMode!]) fileHandle?.acceptConnectionInBackgroundAndNotify(forModes: [RunLoop.current.currentMode!])
} }
@ -76,7 +78,12 @@ public class SocketController {
Logger().debug("Socket controller has new data available") Logger().debug("Socket controller has new data available")
guard let new = notification.object as? FileHandle else { return } guard let new = notification.object as? FileHandle else { return }
Logger().debug("Socket controller received new file handle") Logger().debug("Socket controller received new file handle")
handler?(new, new) if((handler?(new, new)) == true) {
Logger().debug("Socket controller handled data, wait for more data")
new.waitForDataInBackgroundAndNotify()
} else {
Logger().debug("Socket controller called with empty data, socked closed")
}
} }
} }

View File

@ -6,6 +6,7 @@ public class PublicKeyFileStoreController {
private let logger = Logger() private let logger = Logger()
private let directory: String private let directory: String
private let keyWriter = OpenSSHKeyWriter()
/// Initializes a PublicKeyFileStoreController. /// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: String) { public init(homeDirectory: String) {
@ -21,7 +22,6 @@ public class PublicKeyFileStoreController {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: directory)) try? FileManager.default.removeItem(at: URL(fileURLWithPath: directory))
} }
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil) try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
let keyWriter = OpenSSHKeyWriter()
for secret in secrets { for secret in secrets {
let path = path(for: secret) let path = path(for: secret)
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue } guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
@ -35,7 +35,8 @@ public class PublicKeyFileStoreController {
/// - Returns: The path to the Secret's public key. /// - Returns: The path to the Secret's public key.
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to. /// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
public func path<SecretType: Secret>(for secret: SecretType) -> String { public func path<SecretType: Secret>(for secret: SecretType) -> String {
directory.appending("/").appending("\(secret.name.replacingOccurrences(of: " ", with: "-")).pub") let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex).pub")
} }
} }

View File

@ -56,6 +56,9 @@ public protocol SecretStoreModifiable: SecretStore {
extension NSNotification.Name { extension NSNotification.Name {
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed secrets)
public static let secretStoreUpdated = NSNotification.Name("com.maxgoedjen.Secretive.secretStore.updated") public static let secretStoreUpdated = NSNotification.Name("com.maxgoedjen.Secretive.secretStore.updated")
// Internal notification that keys were reloaded from the backing store.
public static let secretStoreReloaded = NSNotification.Name("com.maxgoedjen.Secretive.secretStore.reloaded")
} }

View File

@ -24,7 +24,7 @@ extension SecureEnclave {
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in
self.reloadSecrets(notify: false) self.reloadSecrets(notifyAgent: false)
} }
loadSecrets() loadSecrets()
} }
@ -171,12 +171,13 @@ extension SecureEnclave {
extension SecureEnclave.Store { extension SecureEnclave.Store {
/// Reloads all secrets from the store. /// Reloads all secrets from the store.
/// - Parameter notify: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well. /// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
private func reloadSecrets(notify: Bool = true) { private func reloadSecrets(notifyAgent: Bool = true) {
secrets.removeAll() secrets.removeAll()
loadSecrets() loadSecrets()
if notify { NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
DistributedNotificationCenter.default().post(name: .secretStoreUpdated, object: nil) if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
} }
} }

View File

@ -16,7 +16,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
list.add(store: SmartCard.Store()) list.add(store: SmartCard.Store())
return list return list
}() }()
private let updater = Updater() private let updater = Updater(checkOnLaunch: false)
private let notifier = Notifier() private let notifier = Notifier()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private lazy var agent: Agent = { private lazy var agent: Agent = {
@ -33,15 +33,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
DispatchQueue.main.async { DispatchQueue.main.async {
self.socketController.handler = self.agent.handle(reader:writer:) self.socketController.handler = self.agent.handle(reader:writer:)
} }
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { [self] _ in NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
} }
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
notifier.prompt() notifier.prompt()
// updateSink = updater.$update.sink { update in updateSink = updater.$update.sink { update in
// guard let update = update else { return } guard let update = update else { return }
// self.notifier.notify(update: update, ignore: self.updater.ignore(release:)) self.notifier.notify(update: update, ignore: self.updater.ignore(release:))
// } }
} }
} }

View File

@ -16,7 +16,6 @@ struct Secretive: App {
}() }()
private let agentStatusChecker = AgentStatusChecker() private let agentStatusChecker = AgentStatusChecker()
private let justUpdatedChecker = JustUpdatedChecker() private let justUpdatedChecker = JustUpdatedChecker()
private let updater = Updater()
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false @AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false @State private var showingSetup = false
@ -26,15 +25,11 @@ struct Secretive: App {
WindowGroup { WindowGroup {
ContentView<Updater, AgentStatusChecker>(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup) ContentView<Updater, AgentStatusChecker>(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environmentObject(storeList) .environmentObject(storeList)
.environmentObject(updater) .environmentObject(Updater(checkOnLaunch: hasRunSetup))
.environmentObject(agentStatusChecker) .environmentObject(agentStatusChecker)
.onAppear { .onAppear {
if !hasRunSetup { if !hasRunSetup {
showingSetup = true showingSetup = true
} else {
Task { [updater] in
await updater.checkForUpdates()
}
} }
} }
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in

View File

@ -32,6 +32,9 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
appPathNotice appPathNotice
newItem newItem
} }
.sheet(isPresented: $runningSetup) {
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
}
} }
} }
@ -119,9 +122,6 @@ extension ContentView {
EmptyView() EmptyView()
} }
} }
.sheet(isPresented: $runningSetup) {
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
}
) )
} }
} }

View File

@ -18,9 +18,7 @@ struct UpdateDetailView<UpdaterType: Updater>: View {
HStack { HStack {
if !update.critical { if !update.critical {
Button("Ignore") { Button("Ignore") {
Task { [updater, update] in updater.ignore(release: update)
await updater.ignore(release: update)
}
} }
Spacer() Spacer()
} }