Swift 6 / Concurrency fixes (#578)

* Enable language mode

* WIP

* WIP

* Fix concurrency issues in SmartCardStore

* Switch to SMAppService

* Bump runners

* Base

* Finish Testing migration

* Tweak async for updater

* More

* Backport mutex

* Revert "Backport mutex"

This reverts commit 9b02afb20c.

* WIP

* Reenable

* Fix preview.

* Update package.

* Bump to latest public macOS and Xcode

* Bump back down to 6.1

* Update to Xcode 26.

* Fixed tests.

* More cleanup

* Env fixes

* var->let

* Cleanup

* Persist auth async

* Whitespace.

* Whitespace.

* Cleanup.

* Cleanup

* Redoing locks in actors bc of observable

* Actors.

* .

* Specify b5

* Update package to 6.2

* Fix disabled updater

* Remove preconcurrency warning

* Move updater init
This commit is contained in:
Max Goedjen
2025-08-17 12:38:18 -05:00
committed by GitHub
parent 5cc62b628a
commit 0e6b218f1f
59 changed files with 834 additions and 857 deletions

View File

@@ -5,28 +5,42 @@ import SecureEnclaveSecretKit
import SmartCardSecretKit
import Brief
@main
struct Secretive: App {
extension EnvironmentValues {
private let storeList: SecretStoreList = {
// 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 = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
private let agentStatusChecker = AgentStatusChecker()
private let justUpdatedChecker = JustUpdatedChecker()
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
}
}
@main
struct Secretive: App {
private let justUpdatedChecker = JustUpdatedChecker()
@Environment(\.agentStatusChecker) var agentStatusChecker
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false
@State private var showingCreation = false
@SceneBuilder var body: some Scene {
WindowGroup {
ContentView<Updater, AgentStatusChecker>(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environmentObject(storeList)
.environmentObject(Updater(checkOnLaunch: hasRunSetup))
.environmentObject(agentStatusChecker)
ContentView(showingCreation: $showingCreation, runningSetup: $showingSetup, hasRunSetup: $hasRunSetup)
.environment(EnvironmentValues._secretStoreList)
.onAppear {
if !hasRunSetup {
showingSetup = true
@@ -70,13 +84,12 @@ extension Secretive {
private func reinstallAgent() {
justUpdatedChecker.check()
LaunchAgentController().install {
// Wait a second for launchd to kick in (next runloop isn't enough).
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
agentStatusChecker.check()
if !agentStatusChecker.running {
forceLaunchAgent()
}
Task {
await LaunchAgentController().install()
try? await Task.sleep(for: .seconds(1))
agentStatusChecker.check()
if !agentStatusChecker.running {
forceLaunchAgent()
}
}
}
@@ -84,7 +97,8 @@ extension Secretive {
private func forceLaunchAgent() {
// We've run setup, we didn't just update, launchd is just not doing it's thing.
// Force a launch directly.
LaunchAgentController().forceLaunch { _ in
Task {
_ = await LaunchAgentController().forceLaunch()
agentStatusChecker.check()
}
}

View File

@@ -2,18 +2,22 @@ import Foundation
import Combine
import AppKit
import SecretKit
import Observation
protocol AgentStatusCheckerProtocol: ObservableObject {
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
var running: Bool { get }
var developmentBuild: Bool { get }
func check()
}
class AgentStatusChecker: ObservableObject, AgentStatusCheckerProtocol {
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
@Published var running: Bool = false
var running: Bool = false
init() {
check()
nonisolated init() {
Task { @MainActor in
check()
}
}
func check() {

View File

@@ -2,13 +2,13 @@ import Foundation
import Combine
import AppKit
protocol JustUpdatedCheckerProtocol: ObservableObject {
protocol JustUpdatedCheckerProtocol: Observable {
var justUpdated: Bool { get }
}
class JustUpdatedChecker: ObservableObject, JustUpdatedCheckerProtocol {
@Observable class JustUpdatedChecker: JustUpdatedCheckerProtocol {
@Published var justUpdated: Bool = false
var justUpdated: Bool = false
init() {
check()

View File

@@ -8,38 +8,45 @@ struct LaunchAgentController {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
func install(completion: (() -> Void)? = nil) {
func install() async {
logger.debug("Installing agent")
_ = setEnabled(false)
// This is definitely a bit of a "seems to work better" thing but:
// Seems to more reliably hit if these are on separate runloops, otherwise it seems like it sometimes doesn't kill old
// and start new?
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
try? await Task.sleep(for: .seconds(1))
await MainActor.run {
_ = setEnabled(true)
completion?()
}
}
func forceLaunch(completion: ((Bool) -> Void)?) {
func forceLaunch() async -> Bool {
logger.debug("Agent is not running, attempting to force launch")
let url = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LoginItems/SecretAgent.app")
let config = NSWorkspace.OpenConfiguration()
config.activates = false
NSWorkspace.shared.openApplication(at: url, configuration: config) { app, error in
DispatchQueue.main.async {
completion?(error == nil)
}
if let error = error {
logger.error("Error force launching \(error.localizedDescription)")
} else {
logger.debug("Agent force launched")
}
do {
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
logger.debug("Agent force launched")
return true
} catch {
logger.error("Error force launching \(error.localizedDescription)")
return false
}
}
private func setEnabled(_ enabled: Bool) -> Bool {
SMLoginItemSetEnabled(Bundle.main.agentBundleID as CFString, enabled)
let service = SMAppService.loginItem(identifier: Bundle.main.agentBundleID)
do {
if enabled {
try service.register()
} else {
try service.unregister()
}
return true
} catch {
return false
}
}
}

View File

@@ -2676,6 +2676,7 @@
}
},
"empty_store_modifiable_title" : {
"extractionState" : "stale",
"localizations" : {
"ca" : {
"stringUnit" : {

View File

@@ -10,4 +10,7 @@ class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {
self.running = running
}
func check() {
}
}

View File

@@ -20,20 +20,20 @@ extension Preview {
extension Preview {
class Store: SecretStore, ObservableObject {
@Observable final class Store: SecretStore {
let isAvailable = true
let id = UUID()
var name: String { "Preview Store" }
@Published var secrets: [Secret] = []
let secrets: [Secret]
init(secrets: [Secret]) {
self.secrets.append(contentsOf: secrets)
self.secrets = secrets
}
init(numberOfRandomSecrets: Int = 5) {
convenience init(numberOfRandomSecrets: Int = 5) {
let new = (0..<numberOfRandomSecrets).map { Secret(name: String(describing: $0)) }
self.secrets.append(contentsOf: new)
self.init(secrets: new)
}
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data {
@@ -56,8 +56,40 @@ extension Preview {
}
class StoreModifiable: Store, SecretStoreModifiable {
override var name: String { "Modifiable Preview Store" }
final class StoreModifiable: SecretStoreModifiable {
let isAvailable = true
let id = UUID()
var name: String { "Modifiable Preview Store" }
let secrets: [Secret]
init(secrets: [Secret]) {
self.secrets = secrets
}
convenience init(numberOfRandomSecrets: Int = 5) {
let new = (0..<numberOfRandomSecrets).map { Secret(name: String(describing: $0)) }
self.init(secrets: new)
}
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data {
return data
}
func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
true
}
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
nil
}
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
}
func reloadSecrets() {
}
func create(name: String, requiresAuthentication: Bool) throws {
}
@@ -72,7 +104,7 @@ extension Preview {
extension Preview {
static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList {
@MainActor static func storeList(stores: [Store] = [], modifiableStores: [StoreModifiable] = []) -> SecretStoreList {
let list = SecretStoreList()
for store in stores {
list.add(store: store)

View File

@@ -1,10 +1,11 @@
import Foundation
import Combine
import Observation
import Brief
class PreviewUpdater: UpdaterProtocol {
@Observable @MainActor final class PreviewUpdater: UpdaterProtocol {
var update: Release? = nil
let update: Release?
let testBuild = false
init(update: Update = .none) {
@@ -18,6 +19,9 @@ class PreviewUpdater: UpdaterProtocol {
}
}
func ignore(release: Release) async {
}
}
extension PreviewUpdater {

View File

@@ -2,12 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.smartcard</key>
<true/>
<key>keychain-access-groups</key>

View File

@@ -4,18 +4,18 @@ import SecureEnclaveSecretKit
import SmartCardSecretKit
import Brief
struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentStatusCheckerProtocol>: View {
struct ContentView: View {
@Binding var showingCreation: Bool
@Binding var runningSetup: Bool
@Binding var hasRunSetup: Bool
@State var showingAgentInfo = false
@State var activeSecret: AnySecret.ID?
@State var activeSecret: AnySecret?
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject private var storeList: SecretStoreList
@EnvironmentObject private var updater: UpdaterType
@EnvironmentObject private var agentStatusChecker: AgentStatusCheckerType
@Environment(\.secretStoreList) private var storeList
@Environment(\.updater) private var updater: any UpdaterProtocol
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
@State private var selectedUpdate: Release?
@State private var showingAppPathNotice = false
@@ -106,7 +106,7 @@ extension ContentView {
if let modifiable = storeList.modifiableStore {
CreateSecretView(store: modifiable, showing: $showingCreation)
.onDisappear {
guard let newest = modifiable.secrets.last?.id else { return }
guard let newest = modifiable.secrets.last else { return }
activeSecret = newest
}
}
@@ -197,34 +197,18 @@ extension ContentView {
struct ContentView_Previews: PreviewProvider {
private static let storeList: SecretStoreList = {
let list = SecretStoreList()
list.add(store: SecureEnclave.Store())
list.add(store: SmartCard.Store())
return list
}()
private static let agentStatusChecker = AgentStatusChecker()
private static let justUpdatedChecker = JustUpdatedChecker()
@State var hasRunSetup = false
@State private var showingSetup = false
@State private var showingCreation = false
static var previews: some View {
Group {
// Empty on modifiable and nonmodifiable
ContentView<PreviewUpdater, AgentStatusChecker>(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environmentObject(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
.environmentObject(PreviewUpdater())
.environmentObject(agentStatusChecker)
ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environment(Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]))
.environment(PreviewUpdater())
// 5 items on modifiable and nonmodifiable
ContentView<PreviewUpdater, AgentStatusChecker>(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environmentObject(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
.environmentObject(PreviewUpdater())
.environmentObject(agentStatusChecker)
ContentView(showingCreation: .constant(false), runningSetup: .constant(false), hasRunSetup: .constant(true))
.environment(Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]))
.environment(PreviewUpdater())
}
.environmentObject(agentStatusChecker)
}
}

View File

@@ -3,7 +3,7 @@ import SecretKit
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
@ObservedObject var store: StoreType
@State var store: StoreType
@Binding var showing: Bool
@State private var name = ""
@@ -45,8 +45,10 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
}
func save() {
try! store.create(name: name, requiresAuthentication: requiresAuthentication)
showing = false
Task {
try! await store.create(name: name, requiresAuthentication: requiresAuthentication)
showing = false
}
}
}
@@ -93,14 +95,14 @@ struct ThumbnailPickerView<ValueType: Hashable>: View {
extension ThumbnailPickerView {
struct Item<ValueType: Hashable>: Identifiable {
struct Item<InnerValueType: Hashable>: Identifiable {
let id = UUID()
let value: ValueType
let value: InnerValueType
let name: LocalizedStringKey
let description: LocalizedStringKey
let thumbnail: AnyView
init<ViewType: View>(value: ValueType, name: LocalizedStringKey, description: LocalizedStringKey, thumbnail: ViewType) {
init<ViewType: View>(value: InnerValueType, name: LocalizedStringKey, description: LocalizedStringKey, thumbnail: ViewType) {
self.value = value
self.name = name
self.description = description
@@ -110,10 +112,10 @@ extension ThumbnailPickerView {
}
@MainActor class SystemBackground: ObservableObject {
@MainActor @Observable class SystemBackground {
static let shared = SystemBackground()
@Published var image: NSImage?
var image: NSImage?
private init() {
if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) {

View File

@@ -3,7 +3,7 @@ import SecretKit
struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
@ObservedObject var store: StoreType
@State var store: StoreType
let secret: StoreType.SecretType
var dismissalBlock: (Bool) -> ()
@@ -49,8 +49,10 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
}
func delete() {
try! store.delete(secret: secret)
dismissalBlock(true)
Task {
try! await store.delete(secret: secret)
dismissalBlock(true)
}
}
}

View File

@@ -3,31 +3,17 @@ import SecretKit
struct EmptyStoreView: View {
@ObservedObject var store: AnySecretStore
@Binding var activeSecret: AnySecret.ID?
@State var store: AnySecretStore?
var body: some View {
if store is AnySecretStoreModifiable {
NavigationLink(destination: EmptyStoreModifiableView(), tag: Constants.emptyStoreModifiableTag, selection: $activeSecret) {
Text("empty_store_modifiable_title")
}
EmptyStoreModifiableView()
} else {
NavigationLink(destination: EmptyStoreImmutableView(), tag: Constants.emptyStoreTag, selection: $activeSecret) {
Text("empty_store_nonmodifiable_title")
}
EmptyStoreImmutableView()
}
}
}
extension EmptyStoreView {
enum Constants {
static let emptyStoreModifiableTag: AnyHashable = "emptyStoreModifiableTag"
static let emptyStoreTag: AnyHashable = "emptyStoreTag"
}
}
struct EmptyStoreImmutableView: View {
var body: some View {

View File

@@ -3,7 +3,7 @@ import SecretKit
struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
@ObservedObject var store: StoreType
@State var store: StoreType
let secret: StoreType.SecretType
var dismissalBlock: (_ renamed: Bool) -> ()
@@ -44,7 +44,9 @@ struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
}
func rename() {
try? store.update(secret: secret, name: newName)
dismissalBlock(true)
Task {
try? await store.update(secret: secret, name: newName)
dismissalBlock(true)
}
}
}

View File

@@ -3,7 +3,7 @@ import SecretKit
struct SecretDetailView<SecretType: Secret>: View {
@State var secret: SecretType
let secret: SecretType
private let keyWriter = OpenSSHKeyWriter()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID))

View File

@@ -2,24 +2,30 @@ import SwiftUI
import SecretKit
struct SecretListItemView: View {
@ObservedObject var store: AnySecretStore
@State var store: AnySecretStore
var secret: AnySecret
@Binding var activeSecret: AnySecret.ID?
@State var isDeleting: Bool = false
@State var isRenaming: Bool = false
var deletedSecret: (AnySecret) -> Void
var renamedSecret: (AnySecret) -> Void
var body: some View {
let showingPopupWrapped = Binding(
private var showingPopup: Binding<Bool> {
Binding(
get: { isDeleting || isRenaming },
set: { if $0 == false { isDeleting = false; isRenaming = false } }
set: {
if $0 == false {
isDeleting = false
isRenaming = false
}
}
)
return NavigationLink(destination: SecretDetailView(secret: secret), tag: secret.id, selection: $activeSecret) {
}
var body: some View {
NavigationLink(value: secret) {
if secret.requiresAuthentication {
HStack {
Text(secret.name)
@@ -40,7 +46,7 @@ struct SecretListItemView: View {
}
}
}
.popover(isPresented: showingPopupWrapped) {
.popover(isPresented: showingPopup) {
if let modifiable = store as? AnySecretStoreModifiable {
if isDeleting {
DeleteSecretView(store: modifiable, secret: secret) { deleted in

View File

@@ -55,7 +55,7 @@ struct StepView: View {
.foregroundColor(.green)
.frame(width: max(0, ((width - (Constants.padding * 2)) / Double(numberOfSteps - 1)) * Double(currentStep) - (Constants.circleWidth / 2)), height: 5)
HStack {
ForEach(0..<numberOfSteps) { index in
ForEach(Array(0..<numberOfSteps), id: \.self) { index in
ZStack {
if currentStep > index {
Circle()
@@ -156,8 +156,10 @@ struct SecretAgentSetupView: View {
}
func install() {
LaunchAgentController().install()
buttonAction()
Task {
await LaunchAgentController().install()
buttonAction()
}
}
}

View File

@@ -4,60 +4,60 @@ import SecretKit
struct StoreListView: View {
@Binding var activeSecret: AnySecret.ID?
@EnvironmentObject private var storeList: SecretStoreList
@Binding var activeSecret: AnySecret?
@Environment(\.secretStoreList) private var storeList
private func secretDeleted(secret: AnySecret) {
activeSecret = nextDefaultSecret
}
private func secretRenamed(secret: AnySecret) {
activeSecret = secret.id
activeSecret = secret
}
var body: some View {
NavigationView {
NavigationSplitView {
List(selection: $activeSecret) {
ForEach(storeList.stores) { store in
if store.isAvailable {
Section(header: Text(store.name)) {
if store.secrets.isEmpty {
EmptyStoreView(store: store, activeSecret: $activeSecret)
} else {
ForEach(store.secrets) { secret in
SecretListItemView(
store: store,
secret: secret,
activeSecret: $activeSecret,
deletedSecret: self.secretDeleted,
renamedSecret: self.secretRenamed
)
}
ForEach(store.secrets) { secret in
SecretListItemView(
store: store,
secret: secret,
deletedSecret: secretDeleted,
renamedSecret: secretRenamed
)
}
}
}
}
}
.listStyle(SidebarListStyle())
.onAppear {
activeSecret = nextDefaultSecret
} 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)
}
.frame(minWidth: 100, idealWidth: 240)
}
.navigationSplitViewStyle(.balanced)
.onAppear {
activeSecret = nextDefaultSecret
}
.frame(minWidth: 100, idealWidth: 240)
}
}
extension StoreListView {
var nextDefaultSecret: AnyHashable? {
let fallback: AnyHashable
if storeList.modifiableStore?.isAvailable ?? false {
fallback = EmptyStoreView.Constants.emptyStoreModifiableTag
} else {
fallback = EmptyStoreView.Constants.emptyStoreTag
}
return storeList.stores.compactMap(\.secrets.first).first?.id ?? fallback
private var nextDefaultSecret: AnySecret? {
return storeList.stores.first(where: { !$0.secrets.isEmpty })?.secrets.first
}
}

View File

@@ -1,9 +1,9 @@
import SwiftUI
import Brief
struct UpdateDetailView<UpdaterType: Updater>: View {
struct UpdateDetailView: View {
@EnvironmentObject var updater: UpdaterType
@Environment(\.updater) var updater: any UpdaterProtocol
let update: Release
@@ -18,7 +18,9 @@ struct UpdateDetailView<UpdaterType: Updater>: View {
HStack {
if !update.critical {
Button("update_ignore_button") {
updater.ignore(release: update)
Task {
await updater.ignore(release: update)
}
}
Spacer()
}