Compare commits

...

18 Commits

Author SHA1 Message Date
Max Goedjen
df2b7881c4 WIP 2025-09-01 19:37:59 -07:00
Max Goedjen
74ddb9595b WIP 2025-09-01 19:31:16 -07:00
Max Goedjen
0980cdffcd WIP 2025-09-01 19:25:14 -07:00
Max Goedjen
90d55726bb WIP 2025-09-01 18:00:58 -07:00
Max Goedjen
a640d11b00 WIP 2025-09-01 17:50:40 -07:00
Max Goedjen
f3ce6b9d0f WIP 2025-09-01 17:43:33 -07:00
Max Goedjen
ea96dd88eb Cleanup 2025-09-01 16:27:15 -07:00
Max Goedjen
4d84621b3d WIP 2025-09-01 16:10:27 -07:00
Max Goedjen
2d05a7b0f3 WIP 2025-09-01 15:22:52 -07:00
Max Goedjen
c8d90ba455 WIP 2025-09-01 15:09:27 -07:00
Max Goedjen
9299bf343f WIP 2025-09-01 14:52:17 -07:00
Max Goedjen
fa658646d7 WIP 2025-08-31 13:24:37 -07:00
Max Goedjen
cd76bb95ec Tweaks. 2025-08-31 00:58:16 -07:00
Max Goedjen
b949d846c1 WIP 2025-08-30 18:56:52 -07:00
Max Goedjen
19760f1e02 Merge branch 'main' of github.com:maxgoedjen/secretive into newsetup 2025-08-30 15:40:52 -07:00
Max Goedjen
f60a44c599 WIP 2025-08-30 13:55:19 -07:00
Max Goedjen
260e63341d Merge branch 'main' into newsetup 2025-08-27 23:50:06 -07:00
Max Goedjen
cbf903deb7 WIP 2025-08-25 00:48:07 -07:00
23 changed files with 1526 additions and 457 deletions

View File

@@ -1,6 +1,108 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"agent_details_could_not_start_error" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Secretive was unable to get SecretAgent to launch. Please try restarting your Mac, and if that doesn't work, file an issue on GitHub."
}
}
}
},
"agent_details_disable_agent_button" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Disable Agent"
}
}
}
},
"agent_details_restart_agent_button" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Restart Agent"
}
}
}
},
"agent_details_running_since_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Running Since"
}
}
}
},
"agent_details_socket_path_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Socket Path"
}
}
}
},
"agent_details_start_agent_button" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Start Agent"
}
}
}
},
"agent_details_start_agent_button_starting" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Starting Agent"
}
}
}
},
"agent_details_version_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Version"
}
}
}
},
"agent_not_running_notice_detail_description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "SecretAgent is a process that runs in the background to sign requests, so you don't need to keep Secretive open all the time.\n\n**Secretive will not be able to function properly unless the agent is installed and running.**"
}
}
}
},
"agent_not_running_notice_title" : {
"extractionState" : "manual",
"localizations" : {
@@ -386,6 +488,17 @@
}
}
},
"agentDetailsLocationTitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Secret Agent Location"
}
}
}
},
"app_menu_help_button" : {
"extractionState" : "manual",
"localizations" : {
@@ -1161,6 +1274,17 @@
}
}
},
"copy_button" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Copy"
}
}
}
},
"copyable_click_to_copy_button" : {
"extractionState" : "manual",
"localizations" : {
@@ -1497,7 +1621,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This shows at the end of your public key."
"value" : "This shows at the end of your public key. Its usually an email address."
}
}
}
@@ -3008,6 +3132,248 @@
}
}
},
"integrations_add_this_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add This:"
}
}
}
},
"integrations_apps_row_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apps"
}
}
}
},
"integrations_community_apps_list_description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "There's a community-maintained list of instructions for apps on GitHub. If the app you're looking for isn't supported, create an issue and the community may be able to help."
}
}
}
},
"integrations_community_shell_list_description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "There's a community-maintained list of shell instructions on GitHub. If the shell you're looking for isn't supported, create an issue and the community may be able to help."
}
}
}
},
"integrations_getting_started_multiple_config" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "You can configure more than one tool, they generally won't interfere with each other."
}
}
}
},
"integrations_getting_started_row_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Getting Started"
}
}
}
},
"integrations_getting_started_section_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Integrations"
}
}
}
},
"integrations_getting_started_suggestion_git" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "If you're trying to sign your git commits, set up Git Signing."
}
}
}
},
"integrations_getting_started_suggestion_shell" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "If you're trying to configure anything your command line runs to use Secretive, configure your shell."
}
}
}
},
"integrations_getting_started_suggestion_shell_default" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "If you don't known what shell you use and haven't changed it, you're probably using `%(shellName)@`."
}
}
}
},
"integrations_getting_started_suggestion_ssh" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "If you're trying to authenticate with an SSH server or authenticating with a service like GitHub over SSH, configure your SSH client."
}
}
}
},
"integrations_getting_started_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configuring Tools for Secretive"
}
}
}
},
"integrations_getting_started_title_description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Most tools will try and look for SSH keys on disk in `~/.ssh`. To use Secretive, we need to configure those tools to talk to Secretive instead."
}
}
}
},
"integrations_getting_started_what_should_i_configure_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "What Should I Configure?"
}
}
}
},
"integrations_other_section_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Other"
}
}
}
},
"integrations_other_shell_row_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "other"
}
}
}
},
"integrations_path_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configuration File"
}
}
}
},
"integrations_shell_section_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Shell"
}
}
}
},
"integrations_system_section_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "System"
}
}
}
},
"integrations_view_other_github_link" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "View on GitHub"
}
}
}
},
"integrations_web_link" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "View Documentation on Web"
}
}
}
},
"integrationsMenuBarTitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Integrations…"
}
}
}
},
"no_secure_storage_description" : {
"extractionState" : "manual",
"localizations" : {
@@ -3395,6 +3761,17 @@
}
}
},
"reveal_in_finder_button" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Reveal in Finder"
}
}
}
},
"secret_detail_md5_fingerprint_label" : {
"extractionState" : "manual",
"localizations" : {
@@ -3892,6 +4269,16 @@
}
}
},
"Setup" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Setup"
}
}
}
},
"setup_agent_activity_monitor_description" : {
"extractionState" : "manual",
"localizations" : {
@@ -4176,6 +4563,50 @@
}
}
},
"setup_done_button" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Done"
}
}
}
},
"setup_integrations_button" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configure"
}
}
}
},
"setup_integrations_description" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tell the tools you use how to talk to Secretive."
}
}
}
},
"setup_integrations_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Configure Integrations"
}
}
}
},
"setup_ssh_add_for_me_button" : {
"extractionState" : "manual",
"localizations" : {
@@ -4750,7 +5181,7 @@
}
}
},
"setup_updates_ok" : {
"setup_updates_ok_button" : {
"extractionState" : "manual",
"localizations" : {
"ca" : {
@@ -4963,6 +5394,17 @@
}
}
},
"setupStepCompleteButton" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Done"
}
}
}
},
"signed_notification_description" : {
"comment" : "When the user performs an action using a secret, they're shown a notification describing what happened. This is the description, showing which secret was used. The placeholder is the name of the secret.",
"extractionState" : "manual",

View File

@@ -4,7 +4,7 @@ import OSLog
/// Manages storage and lookup for OpenSSH certificates.
public actor OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHPublicKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]

View File

@@ -5,12 +5,12 @@ import OSLog
public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let directory: String
private let directory: URL
private let keyWriter = OpenSSHPublicKeyWriter()
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: String) {
directory = homeDirectory.appending("/PublicKeys")
public init(homeDirectory: URL) {
directory = homeDirectory.appending(component: "PublicKeys")
}
/// Writes out the keys specified to disk.
@@ -20,7 +20,7 @@ public final class PublicKeyFileStoreController: Sendable {
logger.log("Writing public keys to disk")
if clear {
let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) }))
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? []
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? []
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" }
let untracked = Set(fullPathContents)
@@ -29,7 +29,7 @@ public final class PublicKeyFileStoreController: Sendable {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
}
}
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
for secret in secrets {
let path = publicKeyPath(for: secret)
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
@@ -44,14 +44,14 @@ public final class PublicKeyFileStoreController: Sendable {
/// - 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 publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex).pub")
return directory.appending(component: "\(minimalHex).pub").path()
}
/// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
public var hasAnyCertificates: Bool {
do {
return try FileManager.default
.contentsOfDirectory(atPath: directory)
.contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") }
.isEmpty == false
} catch {
@@ -65,7 +65,7 @@ public final class PublicKeyFileStoreController: Sendable {
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
public func sshCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex)-cert.pub")
return directory.appending(component: "\(minimalHex)-cert.pub").path()
}
}

View File

@@ -21,7 +21,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}()
private let updater = Updater(checkOnLaunch: true)
private let notifier = Notifier()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
private lazy var agent: Agent = {
Agent(storeList: storeList, witness: notifier)
}()

View File

@@ -36,7 +36,6 @@
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5065E312295517C500E16645 /* ToolbarButtonStyle.swift */; };
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C12516F303004B5A36 /* SetupView.swift */; };
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; };
5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */; };
506772C72424784600034DED /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 506772C62424784600034DED /* Credits.rtf */; };
506772C92425BB8500034DED /* NoStoresView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506772C82425BB8500034DED /* NoStoresView.swift */; };
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5079BA0E250F29BF00EA86F4 /* StoreListView.swift */; };
@@ -49,8 +48,12 @@
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; };
50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79324026B7600D209EA /* Preview Assets.xcassets */; };
50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; };
50AE97002E5C1A420018C710 /* IntegrationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */; };
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B8550C24138C4F009958AC /* DeleteSecretView.swift */; };
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */; };
50BDCB722E63BAF20072D2E7 /* AgentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */; };
50BDCB742E6436CA0072D2E7 /* ErrorStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */; };
50BDCB762E6450950072D2E7 /* ConfigurationItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */; };
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C385A42407A76D00AF2719 /* SecretDetailView.swift */; };
50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */; };
/* End PBXBuildFile section */
@@ -120,7 +123,6 @@
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarButtonStyle.swift; sourceTree = "<group>"; };
5066A6C12516F303004B5A36 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
5066A6C72516FE6E004B5A36 /* CopyableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableView.swift; sourceTree = "<group>"; };
5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellConfigurationController.swift; sourceTree = "<group>"; };
506772C62424784600034DED /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = "<group>"; };
506772C82425BB8500034DED /* NoStoresView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoStoresView.swift; sourceTree = "<group>"; };
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListView.swift; sourceTree = "<group>"; };
@@ -138,8 +140,12 @@
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>"; };
50A3B79924026B7600D209EA /* SecretAgent.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SecretAgent.entitlements; sourceTree = "<group>"; };
50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationsView.swift; sourceTree = "<group>"; };
50B8550C24138C4F009958AC /* DeleteSecretView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeleteSecretView.swift; sourceTree = "<group>"; };
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStoreView.swift; sourceTree = "<group>"; };
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentStatusView.swift; sourceTree = "<group>"; };
50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorStyle.swift; sourceTree = "<group>"; };
50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationItemView.swift; sourceTree = "<group>"; };
50C385A42407A76D00AF2719 /* SecretDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretDetailView.swift; sourceTree = "<group>"; };
50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonStyle.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -254,7 +260,11 @@
506772C82425BB8500034DED /* NoStoresView.swift */,
50153E1F250AFCB200525160 /* UpdateView.swift */,
5066A6C12516F303004B5A36 /* SetupView.swift */,
50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */,
50AE96FF2E5C1A420018C710 /* IntegrationsView.swift */,
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
50BDCB732E6436C60072D2E7 /* ErrorStyle.swift */,
50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -266,7 +276,6 @@
5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */,
50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */,
50571E0424393D1500F76F6C /* LaunchAgentController.swift */,
5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */,
);
path = Controllers;
sourceTree = "<group>";
@@ -433,6 +442,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
50BDCB742E6436CA0072D2E7 /* ErrorStyle.swift in Sources */,
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */,
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
@@ -442,16 +452,18 @@
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */,
5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */,
50033AC327813F1700253856 /* BundleIDs.swift in Sources */,
50BDCB722E63BAF20072D2E7 /* AgentStatusView.swift in Sources */,
508A58B3241ED2180069DC07 /* AgentStatusChecker.swift in Sources */,
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */,
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */,
50AE97002E5C1A420018C710 /* IntegrationsView.swift in Sources */,
50153E20250AFCB200525160 /* UpdateView.swift in Sources */,
50571E0524393D1500F76F6C /* LaunchAgentController.swift in Sources */,
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */,
50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */,
50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */,
50BDCB762E6450950072D2E7 /* ConfigurationItemView.swift in Sources */,
50617D8323FCE48E0099B055 /* App.swift in Sources */,
506772C92425BB8500034DED /* NoStoresView.swift in Sources */,
50153E22250DECA300525160 /* SecretListItemView.swift in Sources */,
@@ -647,10 +659,18 @@
ENABLE_APP_SANDBOX = YES;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
INFOPLIST_FILE = Secretive/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -679,10 +699,18 @@
ENABLE_APP_SANDBOX = YES;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
INFOPLIST_FILE = Secretive/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -783,10 +811,18 @@
ENABLE_APP_SANDBOX = YES;
ENABLE_ENHANCED_SECURITY = YES;
ENABLE_HARDENED_RUNTIME = NO;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_POINTER_AUTHENTICATION = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
INFOPLIST_FILE = Secretive/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -809,8 +845,17 @@
DEVELOPMENT_ASSET_PATHS = "\"SecretAgent/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
INFOPLIST_FILE = SecretAgent/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -835,8 +880,17 @@
DEVELOPMENT_TEAM = Z72PRUAWF6;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
INFOPLIST_FILE = SecretAgent/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -862,8 +916,17 @@
DEVELOPMENT_TEAM = Z72PRUAWF6;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
INFOPLIST_FILE = SecretAgent/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",

View File

@@ -37,6 +37,7 @@ struct Secretive: App {
@Environment(\.agentStatusChecker) var agentStatusChecker
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State private var showingSetup = false
@State private var showingIntegrations = false
@State private var showingCreation = false
@SceneBuilder var body: some Scene {
@@ -58,8 +59,16 @@ struct Secretive: App {
forceLaunchAgent()
}
}
.sheet(isPresented: $showingIntegrations) {
IntegrationsView()
}
}
.commands {
CommandGroup(before: CommandGroupPlacement.appSettings) {
Button(.integrationsMenuBarTitle, systemImage: "app.connected.to.app.below.fill") {
showingIntegrations = true
}
}
CommandGroup(after: CommandGroupPlacement.newItem) {
Button(.appMenuNewSecretButton) {
showingCreation = true
@@ -72,7 +81,7 @@ struct Secretive: App {
}
}
CommandGroup(after: .help) {
Button(.appMenuSetupButton) {
Button("Setup") {
showingSetup = true
}
}
@@ -87,7 +96,7 @@ extension Secretive {
private func reinstallAgent() {
justUpdatedChecker.check()
Task {
await LaunchAgentController().install()
_ = await LaunchAgentController().install()
try? await Task.sleep(for: .seconds(1))
agentStatusChecker.check()
if !agentStatusChecker.running {

View File

@@ -6,12 +6,14 @@ import Observation
@MainActor protocol AgentStatusCheckerProtocol: Observable, Sendable {
var running: Bool { get }
var developmentBuild: Bool { get }
var process: NSRunningApplication? { get }
func check()
}
@Observable @MainActor final class AgentStatusChecker: AgentStatusCheckerProtocol {
var running: Bool = false
var process: NSRunningApplication? = nil
nonisolated init() {
Task { @MainActor in
@@ -20,32 +22,39 @@ import Observation
}
func check() {
running = instanceSecretAgentProcess != nil
process = instanceSecretAgentProcess
running = process != nil
}
// All processes, including ones from older versions, etc
var secretAgentProcesses: [NSRunningApplication] {
NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.agentBundleID)
var allSecretAgentProcesses: [NSRunningApplication] {
NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.agentBundleID)
}
// The process corresponding to this instance of Secretive
var instanceSecretAgentProcess: NSRunningApplication? {
let agents = secretAgentProcesses
// FIXME: CHECK VERSION
let agents = allSecretAgentProcesses
for agent in agents {
guard let url = agent.bundleURL else { continue }
if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) {
if url.absoluteString.hasPrefix(Bundle.main.bundleURL.absoluteString) || (url.isXcodeURL && developmentBuild) {
return agent
}
}
return nil
}
// Whether Secretive is being run in an Xcode environment.
var developmentBuild: Bool {
Bundle.main.bundleURL.absoluteString.contains("/Library/Developer/Xcode")
Bundle.main.bundleURL.isXcodeURL
}
}
extension URL {
var isXcodeURL: Bool {
absoluteString.contains("/Library/Developer/Xcode")
}
}

View File

@@ -8,16 +8,28 @@ struct LaunchAgentController {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "LaunchAgentController")
func install() async {
func install() async -> Bool {
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?
try? await Task.sleep(for: .seconds(1))
await MainActor.run {
_ = setEnabled(true)
let result = await MainActor.run {
setEnabled(true)
}
try? await Task.sleep(for: .seconds(1))
return result
}
func uninstall() async -> Bool {
logger.debug("Uninstalling agent")
try? await Task.sleep(for: .seconds(1))
let result = await MainActor.run {
setEnabled(false)
}
try? await Task.sleep(for: .seconds(1))
return result
}
func forceLaunch() async -> Bool {
@@ -28,6 +40,7 @@ struct LaunchAgentController {
do {
try await NSWorkspace.shared.openApplication(at: url, configuration: config)
logger.debug("Agent force launched")
try? await Task.sleep(for: .seconds(1))
return true
} catch {
logger.error("Error force launching \(error.localizedDescription)")
@@ -36,7 +49,7 @@ struct LaunchAgentController {
}
private func setEnabled(_ enabled: Bool) -> Bool {
let service = SMAppService.loginItem(identifier: Bundle.main.agentBundleID)
let service = SMAppService.loginItem(identifier: Bundle.agentBundleID)
do {
if enabled {
try service.register()

View File

@@ -1,63 +0,0 @@
import Foundation
import Cocoa
import SecretKit
struct ShellConfigurationController {
let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
var shellInstructions: [ShellConfigInstruction] {
[
ShellConfigInstruction(shell: "global",
shellConfigDirectory: "~/.ssh/",
shellConfigFilename: "config",
text: "Host *\n\tIdentityAgent \(socketPath)"),
ShellConfigInstruction(shell: "zsh",
shellConfigDirectory: "~/",
shellConfigFilename: ".zshrc",
text: "export SSH_AUTH_SOCK=\(socketPath)"),
ShellConfigInstruction(shell: "bash",
shellConfigDirectory: "~/",
shellConfigFilename: ".bashrc",
text: "export SSH_AUTH_SOCK=\(socketPath)"),
ShellConfigInstruction(shell: "fish",
shellConfigDirectory: "~/.config/fish",
shellConfigFilename: "config.fish",
text: "set -x SSH_AUTH_SOCK \(socketPath)"),
]
}
@MainActor func addToShell(shellInstructions: ShellConfigInstruction) -> Bool {
let openPanel = NSOpenPanel()
// This is sync, so no need to strongly retain
let delegate = Delegate(name: shellInstructions.shellConfigFilename)
openPanel.delegate = delegate
openPanel.message = "Select \(shellInstructions.shellConfigFilename) to let Secretive configure your shell automatically."
openPanel.prompt = "Add to \(shellInstructions.shellConfigFilename)"
openPanel.canChooseFiles = true
openPanel.canChooseDirectories = false
openPanel.showsHiddenFiles = true
openPanel.directoryURL = URL(fileURLWithPath: shellInstructions.shellConfigDirectory)
openPanel.nameFieldStringValue = shellInstructions.shellConfigFilename
openPanel.allowedContentTypes = [.symbolicLink, .data, .plainText]
openPanel.runModal()
guard let fileURL = openPanel.urls.first else { return false }
let handle: FileHandle
do {
handle = try FileHandle(forUpdating: fileURL)
guard let existing = try handle.readToEnd(),
let existingString = String(data: existing, encoding: .utf8) else { return false }
guard !existingString.contains(shellInstructions.text) else {
return true
}
try handle.seekToEnd()
} catch {
return false
}
handle.write(Data("\n# Secretive Config\n\(shellInstructions.text)\n".utf8))
return true
}
}

View File

@@ -1,7 +1,11 @@
import Foundation
extension Bundle {
public var agentBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "Host", with: "SecretAgent"))!}
public var hostBundleID: String {(self.bundleIdentifier?.replacingOccurrences(of: "SecretAgent", with: "Host"))!}
public static var agentBundleID: String {
Bundle.main.bundleIdentifier!.replacingOccurrences(of: "Host", with: "SecretAgent")
}
public static var hostBundleID: String {
Bundle.main.bundleIdentifier!.replacingOccurrences(of: "SecretAgent", with: "Host")
}
}

View File

@@ -1,12 +1,15 @@
import Foundation
import AppKit
class PreviewAgentStatusChecker: AgentStatusCheckerProtocol {
let running: Bool
let process: NSRunningApplication?
let developmentBuild = false
init(running: Bool = true) {
init(running: Bool = true, process: NSRunningApplication? = nil) {
self.running = running
self.process = process
}
func check() {

View File

@@ -3,10 +3,11 @@ import SwiftUI
struct PrimaryButtonModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme
@Environment(\.isEnabled) var isEnabled
func body(content: Content) -> some View {
// Tinted glass prominent is really hard to read on 26.0.
if #available(macOS 26.0, *), colorScheme == .dark {
if #available(macOS 26.0, *), colorScheme == .dark, isEnabled {
content.buttonStyle(.glassProminent)
} else {
content.buttonStyle(.borderedProminent)
@@ -17,8 +18,77 @@ struct PrimaryButtonModifier: ViewModifier {
extension View {
func primary() -> some View {
func primaryButton() -> some View {
modifier(PrimaryButtonModifier())
}
}
struct MenuButtonModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(macOS 26.0, *) {
content
.glassEffect(.regular.tint(.white.opacity(0.1)), in: .circle)
} else {
content
.buttonStyle(.borderless)
}
}
}
extension View {
func menuButton() -> some View {
modifier(MenuButtonModifier())
}
}
struct NormalButtonModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(macOS 26.0, *) {
content.buttonStyle(.glass)
} else {
content.buttonStyle(.bordered)
}
}
}
extension View {
func normalButton() -> some View {
modifier(NormalButtonModifier())
}
}
struct DangerButtonModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme
func body(content: Content) -> some View {
// Tinted glass prominent is really hard to read on 26.0.
if #available(macOS 26.0, *), colorScheme == .dark {
content.buttonStyle(.glassProminent)
.tint(.red)
.foregroundStyle(.white)
} else {
content.buttonStyle(.borderedProminent)
.tint(.red)
.foregroundStyle(.white)
}
}
}
extension View {
func danger() -> some View {
modifier(DangerButtonModifier())
}
}

View File

@@ -0,0 +1,154 @@
import SwiftUI
struct AgentStatusView: View {
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
var body: some View {
if agentStatusChecker.running {
AgentRunningView()
} else {
AgentNotRunningView()
}
}
}
struct AgentRunningView: View {
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
private let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
var body: some View {
Form {
Section {
if let process = agentStatusChecker.process {
ConfigurationItemView(
title: .agentDetailsLocationTitle,
value: process.bundleURL!.path(),
action: .revealInFinder(process.bundleURL!.path()),
)
ConfigurationItemView(
title: .agentDetailsSocketPathTitle,
value: socketPath,
action: .copy(socketPath),
)
ConfigurationItemView(
title: .agentDetailsVersionTitle,
value: Bundle(url: process.bundleURL!)!.infoDictionary!["CFBundleShortVersionString"] as! String
)
if let launchDate = process.launchDate {
ConfigurationItemView(
title: .agentDetailsRunningSinceTitle,
value: launchDate.formatted()
)
}
}
} header: {
Text(.agentRunningNoticeDetailTitle)
.font(.headline)
.padding(.top)
} footer: {
VStack(alignment: .leading, spacing: 10) {
Text(.agentRunningNoticeDetailDescription)
HStack {
Spacer()
Menu(.agentDetailsRestartAgentButton) {
Button(.agentDetailsDisableAgentButton) {
Task {
_ = await LaunchAgentController()
.uninstall()
agentStatusChecker.check()
}
}
} primaryAction: {
Task {
let controller = LaunchAgentController()
let installed = await controller.install()
if !installed {
_ = await controller.forceLaunch()
}
agentStatusChecker.check()
}
}
}
}
.padding(.vertical)
}
}
.formStyle(.grouped)
.frame(width: 400)
}
}
struct AgentNotRunningView: View {
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
@State var triedRestart = false
@State var loading = false
var body: some View {
Form {
Section {
} header: {
Text(.agentNotRunningNoticeTitle)
.font(.headline)
.padding(.top)
} footer: {
VStack(alignment: .leading, spacing: 10) {
Text(.agentNotRunningNoticeDetailDescription)
HStack {
if !triedRestart {
Spacer()
Button {
guard !loading else { return }
loading = true
Task {
let controller = LaunchAgentController()
let installed = await controller.install()
if !installed {
_ = await controller.forceLaunch()
}
agentStatusChecker.check()
loading = false
if !agentStatusChecker.running {
triedRestart = true
}
}
} label: {
if !loading {
Text(.agentDetailsStartAgentButton)
} else {
HStack {
Text(.agentDetailsStartAgentButtonStarting)
ProgressView()
.controlSize(.mini)
}
}
}
.primaryButton()
} else {
Text(.agentDetailsCouldNotStartError)
.bold()
.foregroundStyle(.red)
}
}
}
.padding(.bottom)
}
}
.formStyle(.grouped)
.frame(width: 400)
}
}
#Preview {
AgentStatusView()
.environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: false))
}
#Preview {
AgentStatusView()
.environment(\.agentStatusChecker, PreviewAgentStatusChecker(running: true, process: .current))
}

View File

@@ -0,0 +1,59 @@
import SwiftUI
struct ConfigurationItemView<Content: View>: View {
enum Action: Hashable {
case copy(String)
case revealInFinder(String)
}
let title: LocalizedStringResource
let content: Content
let action: Action?
init(title: LocalizedStringResource, value: String, action: Action? = nil) where Content == Text {
self.title = title
self.content = Text(value)
.font(.subheadline)
.foregroundStyle(.secondary)
self.action = action
}
init(title: LocalizedStringResource, action: Action? = nil, content: () -> Content) {
self.title = title
self.content = content()
self.action = action
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(title)
Spacer()
switch action {
case .copy(let string):
Button(.copyButton, systemImage: "document.on.document") {
NSPasteboard.general.declareTypes([.string], owner: nil)
NSPasteboard.general.setString(string, forType: .string)
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
case .revealInFinder(let rawPath):
Button(.revealInFinderButton, systemImage: "folder") {
// All foundation-based normalization methods replace this with the container directly.
let processedPath = rawPath.replacingOccurrences(of: "~", with: "/Users/\(NSUserName())")
let url = URL(filePath: processedPath)
let folder = url.deletingLastPathComponent().path()
NSWorkspace.shared.selectFile(processedPath, inFileViewerRootedAtPath: folder)
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
case nil:
EmptyView()
}
}
content
}
}
}

View File

@@ -36,7 +36,7 @@ struct ContentView: View {
toolbarItem(newItemView, id: "new")
}
.sheet(isPresented: $runningSetup) {
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
SetupView(setupComplete: $hasRunSetup)
}
}
@@ -56,7 +56,7 @@ extension ContentView {
}
var needsSetup: Bool {
(runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild
runningSetup || !hasRunSetup
}
/// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message
@@ -66,7 +66,7 @@ extension ContentView {
if needsSetup {
setupNoticeView
} else {
runningNoticeView
agentStatusToolbarView
}
}
@@ -94,7 +94,7 @@ extension ContentView {
.foregroundColor(.white)
})
.buttonStyle(ToolbarButtonStyle(color: color))
.popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in
.sheet(item: $selectedUpdate) { update in
UpdateDetailView(update: update)
}
}
@@ -103,18 +103,17 @@ extension ContentView {
@ViewBuilder
var newItemView: some View {
if storeList.modifiableStore?.isAvailable ?? false {
Button(action: {
Button(.appMenuNewSecretButton, systemImage: "plus") {
showingCreation = true
}, label: {
Image(systemName: "plus")
})
}
.menuButton()
.sheet(isPresented: $showingCreation) {
if let modifiable = storeList.modifiableStore {
CreateSecretView(store: modifiable, showing: $showingCreation)
.onDisappear {
guard let newest = modifiable.secrets.last else { return }
activeSecret = newest
CreateSecretView(store: modifiable) { created in
if let created {
activeSecret = created
}
}
}
}
}
@@ -125,43 +124,44 @@ extension ContentView {
Button(action: {
runningSetup = true
}, label: {
Group {
if hasRunSetup && !agentStatusChecker.running {
Text(.agentNotRunningNoticeTitle)
} else {
Text(.agentSetupNoticeTitle)
}
if !hasRunSetup {
Text(.agentSetupNoticeTitle)
.font(.headline)
}
.font(.headline)
})
.buttonStyle(ToolbarButtonStyle(color: .orange))
}
@ViewBuilder
var runningNoticeView: some View {
var agentStatusToolbarView: some View {
Button(action: {
showingAgentInfo = true
}, label: {
HStack {
Text(.agentRunningNoticeTitle)
.font(.headline)
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
Circle()
.frame(width: 10, height: 10)
.foregroundColor(Color.green)
if agentStatusChecker.running {
Text(.agentRunningNoticeTitle)
.font(.headline)
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
Circle()
.frame(width: 10, height: 10)
.foregroundColor(Color.green)
} else {
Text(.agentNotRunningNoticeTitle)
.font(.headline)
Circle()
.frame(width: 10, height: 10)
.foregroundColor(Color.red)
}
}
})
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
.buttonStyle(
ToolbarButtonStyle(
lightColor: agentStatusChecker.running ? .black.opacity(0.05) : .red.opacity(0.75),
darkColor: agentStatusChecker.running ? .white.opacity(0.05) : .red.opacity(0.5),
)
)
.popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
VStack {
Text(.agentRunningNoticeDetailTitle)
.font(.title)
.padding(5)
Text(.agentRunningNoticeDetailDescription)
.frame(width: 300)
}
.padding()
AgentStatusView()
}
}
@@ -193,7 +193,6 @@ extension ContentView {
}
var attachmentAnchor: PopoverAttachmentAnchor {
// Ideally .point(.bottom), but broken on Sonoma (FB12726503)
.rect(.bounds)
}

View File

@@ -4,13 +4,15 @@ import SecretKit
struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
@State var store: StoreType
@Binding var showing: Bool
@Environment(\.dismiss) private var dismiss
var createdSecret: (AnySecret?) -> Void
@State private var name = ""
@State private var keyAttribution = ""
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
@State private var keyType: KeyType?
@State var advanced = false
@State var errorText: String?
private var authenticationOptions: [AuthenticationRequirement] {
if advanced || authenticationRequirement == .biometryCurrent {
@@ -94,16 +96,24 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
}
}
}
if let errorText {
Section {
} footer: {
Text(verbatim: errorText)
.errorStyle()
}
}
}
HStack {
Toggle(.createSecretAdvancedLabel, isOn: $advanced)
.toggleStyle(.button)
Spacer()
Button(.createSecretCancelButton, role: .cancel) {
showing = false
dismiss()
}
Button(.createSecretCreateButton, action: save)
.primary()
.keyboardShortcut(.return)
.primaryButton()
.disabled(name.isEmpty)
}
.padding()
@@ -117,20 +127,25 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
func save() {
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
Task {
try! await store.create(
name: name,
attributes: .init(
keyType: keyType!,
authentication: authenticationRequirement,
publicKeyAttribution: attribution
do {
let new = try await store.create(
name: name,
attributes: .init(
keyType: keyType!,
authentication: authenticationRequirement,
publicKeyAttribution: attribution
)
)
)
showing = false
createdSecret(AnySecret(new))
dismiss()
} catch {
errorText = error.localizedDescription
}
}
}
}
#Preview {
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
CreateSecretView(store: Preview.StoreModifiable()) { _ in }
}

View File

@@ -28,8 +28,7 @@ struct DeleteSecretConfirmationModifier: ViewModifier {
TextField(secret.name, text: $confirmedSecretName)
if let errorText {
Text(verbatim: errorText)
.foregroundStyle(.red)
.font(.callout)
.errorStyle()
}
Button(.deleteConfirmationDeleteButton, action: delete)
.disabled(confirmedSecretName != secret.name)

View File

@@ -30,21 +30,22 @@ struct EditSecretView<StoreType: SecretStoreModifiable>: View {
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if let errorText {
Text(verbatim: errorText)
.foregroundStyle(.red)
.font(.callout)
} footer: {
if let errorText {
Text(verbatim: errorText)
.errorStyle()
}
}
}
HStack {
Button(.editSaveButton, action: rename)
.disabled(name.isEmpty)
.keyboardShortcut(.return)
Button(.editCancelButton) {
dismissalBlock(false)
}
.keyboardShortcut(.cancelAction)
Button(.editSaveButton, action: rename)
.disabled(name.isEmpty)
.keyboardShortcut(.return)
.primaryButton()
}
.padding()
}

View File

@@ -0,0 +1,19 @@
import SwiftUI
struct ErrorStyleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.foregroundStyle(.red)
.font(.callout)
}
}
extension View {
func errorStyle() -> some View {
modifier(ErrorStyleModifier())
}
}

View File

@@ -0,0 +1,350 @@
import SwiftUI
struct IntegrationsView: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedInstruction: ConfigurationFileInstructions?
private let instructions = Instructions()
var body: some View {
NavigationSplitView {
List(selection: $selectedInstruction) {
ForEach(instructions.instructions) { group in
Section(group.name) {
ForEach(group.instructions) { instruction in
Text(instruction.tool)
.padding(.vertical, 8)
.tag(instruction)
}
}
}
}
} detail: {
IntegrationsDetailView(selectedInstruction: $selectedInstruction)
.fauxToolbar {
Button(.setupDoneButton) {
dismiss()
}
.normalButton()
}
}
.onAppear {
selectedInstruction = instructions.gettingStarted
}
.frame(minHeight: 500)
}
}
extension View {
func fauxToolbar<Content: View>(content: () -> Content) -> some View {
modifier(FauxToolbarModifier(toolbarContent: content()))
}
}
struct FauxToolbarModifier<ToolbarContent: View>: ViewModifier {
var toolbarContent: ToolbarContent
func body(content: Content) -> some View {
VStack(alignment: .leading) {
content
Divider()
HStack {
Spacer()
toolbarContent
.padding(.top, 8)
.padding(.trailing, 16)
.padding(.bottom, 16)
}
}
}
}
struct IntegrationsDetailView: View {
@Binding private var selectedInstruction: ConfigurationFileInstructions?
private let instructions = Instructions()
init(selectedInstruction: Binding<ConfigurationFileInstructions?>) {
_selectedInstruction = selectedInstruction
}
var body: some View {
if let selectedInstruction {
switch selectedInstruction.id {
case .gettingStarted:
Form {
Section(.integrationsGettingStartedTitle) {
Text(.integrationsGettingStartedTitleDescription)
}
Section {
Group {
Text(.integrationsGettingStartedSuggestionSsh)
.onTapGesture {
self.selectedInstruction = instructions.ssh
}
VStack(alignment: .leading, spacing: 5) {
Text(.integrationsGettingStartedSuggestionShell)
Text(.integrationsGettingStartedSuggestionShellDefault(shellName: instructions.defaultShell.tool))
.font(.caption2)
}
.onTapGesture {
self.selectedInstruction = instructions.defaultShell
}
Text(.integrationsGettingStartedSuggestionGit)
.onTapGesture {
self.selectedInstruction = instructions.git
}
}
.foregroundStyle(.link)
} header: {
Text(.integrationsGettingStartedWhatShouldIConfigureTitle)
}
footer: {
Text(.integrationsGettingStartedMultipleConfig)
}
}
.formStyle(.grouped)
case .tool:
Form {
ForEach(selectedInstruction.steps) { stepGroup in
Section {
ConfigurationItemView(title: .integrationsPathTitle, value: stepGroup.path, action: .revealInFinder(stepGroup.path))
ForEach(stepGroup.steps, id: \.self) { step in
ConfigurationItemView(title: .integrationsAddThisTitle, action: .copy(step)) {
HStack {
Text(step)
.padding(8)
.font(.system(.subheadline, design: .monospaced))
Spacer()
}
.frame(maxWidth: .infinity)
.background {
RoundedRectangle(cornerRadius: 6)
.fill(.black.opacity(0.05))
.stroke(.separator, lineWidth: 1)
}
}
}
} footer: {
if let note = stepGroup.note {
Text(note)
.font(.caption)
}
}
}
if let url = selectedInstruction.website {
Section {
Link(destination: url) {
VStack(alignment: .leading, spacing: 5) {
Text(.integrationsWebLink)
.font(.headline)
Text(url.absoluteString)
.font(.caption2)
}
}
}
}
}
.formStyle(.grouped)
case .otherShell:
Form {
Section {
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/shells")!)
} header: {
Text(.integrationsCommunityShellListDescription)
.font(.body)
}
}
.formStyle(.grouped)
case .otherApp:
Form {
Section {
Link(.integrationsViewOtherGithubLink, destination: URL(string: "https://github.com/maxgoedjen/secretive-config-instructions/tree/main/apps")!)
} header: {
Text(.integrationsCommunityAppsListDescription)
.font(.body)
}
}
.formStyle(.grouped)
}
}
}
}
private struct Instructions {
private let socketPath = (NSHomeDirectory().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID) as NSString).appendingPathComponent("socket.ssh") as String
var defaultShell: ConfigurationFileInstructions {
zsh
}
var gettingStarted: ConfigurationFileInstructions = ConfigurationFileInstructions(.integrationsGettingStartedRowTitle, id: .gettingStarted)
var ssh: ConfigurationFileInstructions {
ConfigurationFileInstructions(
tool: "SSH",
configPath: "~/.ssh/config",
configText: "Host *\n\tIdentityAgent \(socketPath)",
website: URL(string: "https://man.openbsd.org/ssh_config.5")!,
note: "You can tell SSH to use a specific key for a given host. See the web documentation for more details.",
)
}
var git: ConfigurationFileInstructions {
ConfigurationFileInstructions(
tool: "Git Signing",
steps: [
.init(path: "~/.gitconfig", steps: [
"""
[user]
signingkey = YOUR_PUBLIC_KEY_PATH
[commit]
gpgsign = true
[gpg]
format = ssh
[gpg "ssh"]
allowedSignersFile = ~/.gitallowedsigners
"""
],
note: "If any section (like [user]) already exists, just add the entries in the existing section."
),
.init(
path: "~/.gitallowedsigners",
steps: [
"YOUR_PUBLIC_KEY"
],
note: "~/.gitallowedsigners probably does not exist. You'll need to create it."
),
],
website: URL(string: "https://git-scm.com/docs/git-config")!,
)
}
var zsh: ConfigurationFileInstructions {
ConfigurationFileInstructions(
tool: "zsh",
configPath: "~/.zshrc",
configText: "export SSH_AUTH_SOCK=\(socketPath)"
)
}
var instructions: [ConfigurationGroup] {
[
ConfigurationGroup(name: .integrationsGettingStartedSectionTitle, instructions: [
gettingStarted
]),
ConfigurationGroup(
name: .integrationsSystemSectionTitle,
instructions: [
ssh,
git,
]
),
ConfigurationGroup(name: .integrationsShellSectionTitle, instructions: [
zsh,
ConfigurationFileInstructions(
tool: "bash",
configPath: "~/.bashrc",
configText: "export SSH_AUTH_SOCK=\(socketPath)"
),
ConfigurationFileInstructions(
tool: "fish",
configPath: "~/.config/fish/config.fish",
configText: "set -x SSH_AUTH_SOCK \(socketPath)"
),
ConfigurationFileInstructions(.integrationsOtherShellRowTitle, id: .otherShell),
]),
ConfigurationGroup(name: .integrationsOtherSectionTitle, instructions: [
ConfigurationFileInstructions(.integrationsAppsRowTitle, id: .otherApp),
]),
]
}
}
struct ConfigurationGroup: Identifiable {
let id = UUID()
var name: LocalizedStringResource
var instructions: [ConfigurationFileInstructions] = []
}
struct ConfigurationFileInstructions: Hashable, Identifiable {
struct StepGroup: Hashable, Identifiable {
let path: String
let steps: [String]
let note: String?
var id: String { path }
init(path: String, steps: [String], note: String? = nil) {
self.path = path
self.steps = steps
self.note = note
}
}
var id: ID
var tool: String
var steps: [StepGroup]
var website: URL?
init(tool: String, configPath: String, configText: String, website: URL? = nil, note: String? = nil) {
self.id = .tool(tool)
self.tool = tool
self.steps = [StepGroup(path: configPath, steps: [configText], note: note)]
self.website = website
}
init(tool: String, steps: [StepGroup], website: URL? = nil) {
self.id = .tool(tool)
self.tool = tool
self.steps = steps
self.website = website
}
init(_ name: LocalizedStringResource, id: ID) {
self.id = id
tool = String(localized: name)
self.steps = []
}
enum ID: Identifiable, Hashable {
case gettingStarted
case tool(String)
case otherShell
case otherApp
var id: String {
switch self {
case .gettingStarted:
"getting_started"
case .tool(let name):
name
case .otherShell:
"other_shell"
case .otherApp:
"other_app"
}
}
}
}
#Preview {
IntegrationsView()
.frame(height: 500)
}

View File

@@ -6,7 +6,7 @@ struct SecretDetailView<SecretType: Secret>: View {
let secret: SecretType
private let keyWriter = OpenSSHPublicKeyWriter()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID))
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
var body: some View {
ScrollView {
@@ -37,12 +37,14 @@ struct SecretDetailView<SecretType: Secret>: View {
}
#if DEBUG
extension URL {
struct SecretDetailView_Previews: PreviewProvider {
static var previews: some View {
SecretDetailView(secret: Preview.Store(numberOfRandomSecrets: 1).secrets[0])
static var agentHomeURL: URL {
URL(fileURLWithPath: URL.homeDirectory.path().replacingOccurrences(of: Bundle.hostBundleID, with: Bundle.agentBundleID))
}
}
#endif
#Preview {
SecretDetailView(secret: Preview.Secret(name: "Demonstration Secret"))
}

View File

@@ -2,227 +2,172 @@ import SwiftUI
struct SetupView: View {
@State var stepIndex = 0
@Binding var visible: Bool
@Environment(\.dismiss) private var dismiss
@Binding var setupComplete: Bool
var body: some View {
GeometryReader { proxy in
VStack {
StepView(numberOfSteps: 3, currentStep: stepIndex, width: proxy.size.width)
GeometryReader { _ in
HStack(spacing: 0) {
SecretAgentSetupView(buttonAction: advance)
.frame(width: proxy.size.width)
SSHAgentSetupView(buttonAction: advance)
.frame(width: proxy.size.width)
UpdaterExplainerView {
visible = false
setupComplete = true
}
.frame(width: proxy.size.width)
}
.offset(x: -proxy.size.width * Double(stepIndex), y: 0)
}
}
}
.frame(minWidth: 500, idealWidth: 500, minHeight: 500, idealHeight: 500)
}
@State var showingIntegrations = false
@State var buttonWidth: CGFloat?
func advance() {
withAnimation(.spring()) {
stepIndex += 1
}
}
}
struct StepView: View {
let numberOfSteps: Int
let currentStep: Int
// Ideally we'd have a geometry reader inside this view doing this for us, but that crashes on 11.0b7
let width: Double
var body: some View {
ZStack(alignment: .leading) {
Rectangle()
.foregroundColor(.blue)
.frame(height: 5)
Rectangle()
.foregroundColor(.green)
.frame(width: max(0, ((width - (Constants.padding * 2)) / Double(numberOfSteps - 1)) * Double(currentStep) - (Constants.circleWidth / 2)), height: 5)
HStack {
ForEach(Array(0..<numberOfSteps), id: \.self) { index in
ZStack {
if currentStep > index {
Circle()
.foregroundColor(.green)
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
Text(.setupStepCompleteSymbol)
.foregroundColor(.white)
.bold()
} else {
Circle()
.foregroundColor(.blue)
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
if currentStep == index {
Circle()
.strokeBorder(Color.white, lineWidth: 3)
.frame(width: Constants.circleWidth, height: Constants.circleWidth)
}
Text(String(describing: index + 1))
.foregroundColor(.white)
.bold()
}
}
if index < numberOfSteps - 1 {
Spacer(minLength: 30)
}
}
}
}.padding(Constants.padding)
}
}
extension StepView {
enum Constants {
static let padding: Double = 15
static let circleWidth: Double = 30
}
}
struct SetupStepView<Content> : View where Content : View {
let title: LocalizedStringResource
let image: Image
let bodyText: LocalizedStringResource
let buttonTitle: LocalizedStringResource
let buttonAction: () -> Void
let content: Content
init(title: LocalizedStringResource, image: Image, bodyText: LocalizedStringResource, buttonTitle: LocalizedStringResource, buttonAction: @escaping () -> Void = {}, @ViewBuilder content: () -> Content) {
self.title = title
self.image = image
self.bodyText = bodyText
self.buttonTitle = buttonTitle
self.buttonAction = buttonAction
self.content = content()
@State var installed = false
@State var updates = false
@State var integrations = false
var allDone: Bool {
installed && updates && integrations
}
var body: some View {
VStack {
Text(title)
.font(.title)
Spacer()
image
VStack(alignment: .leading, spacing: 0) {
StepView(
title: .setupAgentTitle,
description: .setupAgentDescription,
systemImage: "lock.laptopcomputer",
) {
setupButton(
.setupAgentInstallButton,
complete: installed,
width: buttonWidth
) {
installed = true
Task {
await LaunchAgentController().install()
}
}
}
Divider()
StepView(
title: .setupUpdatesTitle,
description: .setupUpdatesDescription,
systemImage: "network.badge.shield.half.filled",
) {
setupButton(
.setupUpdatesOkButton,
complete: updates,
width: buttonWidth
) {
updates = true
}
}
Divider()
StepView(
title: .setupIntegrationsTitle,
description: .setupIntegrationsDescription,
systemImage: "firewall",
) {
setupButton(
.setupIntegrationsButton,
complete: integrations,
width: buttonWidth
) {
showingIntegrations = true
}
}
}
.onPreferenceChange(setupButton.WidthKey.self) { width in
buttonWidth = width
}
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.frame(minWidth: 700, maxWidth: .infinity)
HStack {
Spacer()
Button(.setupDoneButton) {
setupComplete = true
dismiss()
}
.disabled(!allDone)
.primaryButton()
}
}
.interactiveDismissDisabled()
.padding()
.sheet(isPresented: $showingIntegrations, onDismiss: {
integrations = true
}, content: {
IntegrationsView()
})
}
}
struct setupButton: View {
struct WidthKey: @MainActor PreferenceKey {
@MainActor static var defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
if let next = nextValue(), next > (value ?? -1) {
value = next
}
}
}
let label: LocalizedStringResource
let complete: Bool
let action: () -> Void
let width: CGFloat?
@State var currentWidth: CGFloat?
init(_ label: LocalizedStringResource, complete: Bool, width: CGFloat? = nil, action: @escaping () -> Void) {
self.label = label
self.complete = complete
self.action = action
self.width = width
}
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
if complete {
Text(.setupStepCompleteButton)
Image(systemName: "checkmark.circle.fill")
} else {
Text(label)
}
}
.frame(width: width)
.padding(.vertical, 2)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.width
} action: { newValue in
currentWidth = newValue
}
}
.preference(key: WidthKey.self, value: currentWidth)
.primaryButton()
.disabled(complete)
.tint(complete ? .green : nil)
}
}
struct StepView<Content: View>: View {
let title: LocalizedStringResource
let icon: Image
let description: LocalizedStringResource
let actions: Content
init(title: LocalizedStringResource, description: LocalizedStringResource, systemImage: String, actions: () -> Content) {
self.title = title
self.icon = Image(systemName: systemImage)
self.description = description
self.actions = actions()
}
var body: some View {
HStack(spacing: 20) {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 64)
Spacer()
Text(bodyText)
.multilineTextAlignment(.center)
Spacer()
content
Spacer()
Button(buttonTitle) {
buttonAction()
.frame(width: 24)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.bold()
Text(description)
}
}.padding()
}
}
struct SecretAgentSetupView: View {
let buttonAction: () -> Void
var body: some View {
SetupStepView(title: .setupAgentTitle,
image: Image(nsImage: NSApplication.shared.applicationIconImage),
bodyText: .setupAgentDescription,
buttonTitle: .setupAgentInstallButton,
buttonAction: install) {
Text(.setupAgentActivityMonitorDescription)
.multilineTextAlignment(.center)
}
}
func install() {
Task {
await LaunchAgentController().install()
buttonAction()
}
}
}
struct SSHAgentSetupView: View {
let buttonAction: () -> Void
private static let controller = ShellConfigurationController()
@State private var selectedShellInstruction: ShellConfigInstruction = controller.shellInstructions.first!
var body: some View {
SetupStepView(title: .setupSshTitle,
image: Image(systemName: "terminal"),
bodyText: .setupSshDescription,
buttonTitle: .setupSshAddedManuallyButton,
buttonAction: buttonAction) {
Link(.setupThirdPartyFaqLink, destination: URL(string: "https://github.com/maxgoedjen/secretive/blob/main/APP_CONFIG.md")!)
Picker(selection: $selectedShellInstruction, label: EmptyView()) {
ForEach(SSHAgentSetupView.controller.shellInstructions) { instruction in
Text(instruction.shell)
.tag(instruction)
.padding()
}
}.pickerStyle(SegmentedPickerStyle())
CopyableView(title: .setupSshAddToConfigButton(configPath: selectedShellInstruction.shellConfigPath), image: Image(systemName: "greaterthan.square"), text: selectedShellInstruction.text)
Button(.setupSshAddForMeButton) {
let controller = ShellConfigurationController()
if controller.addToShell(shellInstructions: selectedShellInstruction) {
buttonAction()
}
}
}
}
}
class Delegate: NSObject, NSOpenSavePanelDelegate {
private let name: String
init(name: String) {
self.name = name
}
func panel(_ sender: Any, shouldEnable url: URL) -> Bool {
return url.lastPathComponent == name
}
}
struct UpdaterExplainerView: View {
let buttonAction: () -> Void
var body: some View {
SetupStepView(title: .setupUpdatesTitle,
image: Image(systemName: "dot.radiowaves.left.and.right"),
bodyText: .setupUpdatesDescription,
buttonTitle: .setupUpdatesOk,
buttonAction: buttonAction) {
Link(.setupUpdatesReadmore, destination: SetupView.Constants.updaterFAQURL)
Spacer()
actions
}
.padding(20)
}
}
@@ -235,63 +180,6 @@ extension SetupView {
}
struct ShellConfigInstruction: Identifiable, Hashable {
var shell: String
var shellConfigDirectory: String
var shellConfigFilename: String
var text: String
var id: String {
shell
}
var shellConfigPath: String {
return (shellConfigDirectory as NSString).appendingPathComponent(shellConfigFilename)
}
#Preview {
SetupView(setupComplete: .constant(false))
}
#if DEBUG
struct SetupView_Previews: PreviewProvider {
static var previews: some View {
Group {
SetupView(visible: .constant(true), setupComplete: .constant(false))
}
}
}
struct SecretAgentSetupView_Previews: PreviewProvider {
static var previews: some View {
Group {
SecretAgentSetupView(buttonAction: {})
}
}
}
struct SSHAgentSetupView_Previews: PreviewProvider {
static var previews: some View {
Group {
SSHAgentSetupView(buttonAction: {})
}
}
}
struct UpdaterExplainerView_Previews: PreviewProvider {
static var previews: some View {
Group {
UpdaterExplainerView(buttonAction: {})
}
}
}
#endif

View File

@@ -12,7 +12,7 @@ struct UpdateDetailView: View {
Text(.updateVersionName(updateName: update.name)).font(.title)
GroupBox(label: Text(.updateReleaseNotesTitle)) {
ScrollView {
attributedBody
Text(attributedBody)
}
}
HStack {
@@ -35,29 +35,62 @@ struct UpdateDetailView: View {
.frame(maxWidth: 500)
}
var attributedBody: Text {
var text = Text(verbatim: "")
for line in update.body.split(whereSeparator: \.isNewline) {
let attributed: Text
let split = line.split(separator: " ")
let unprefixed = split.dropFirst().joined(separator: " ")
if let prefix = split.first {
switch prefix {
case "#":
attributed = Text(unprefixed).font(.title) + Text(verbatim: "\n")
case "##":
attributed = Text(unprefixed).font(.title2) + Text(verbatim: "\n")
case "###":
attributed = Text(unprefixed).font(.title3) + Text(verbatim: "\n")
var attributedBody: AttributedString {
do {
var text = try AttributedString(
markdown: update.body,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
),
baseURL: URL(string: "https://github.com/maxgoedjen/secretive")!
)
.transformingAttributes(AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self) { key in
let font: Font? = switch key.value?.components.first?.kind {
case .header(level: 1):
Font.title
case .header(level: 2):
Font.title2
case .header(level: 3):
Font.title3
default:
attributed = Text(line) + Text(verbatim: "\n\n")
nil
}
if let font {
key.replace(with: AttributeScopes.SwiftUIAttributes.FontAttribute.self, value: font)
}
} else {
attributed = Text(line) + Text(verbatim: "\n\n")
}
text = text + attributed
let lineBreak = AttributedString("\n\n")
for run in text.runs.reversed() {
text.insert(lineBreak, at: run.range.lowerBound)
}
return text
} catch {
var text = AttributedString()
for line in update.body.split(whereSeparator: \.isNewline) {
let attributed: AttributedString
let split = line.split(separator: " ")
let unprefixed = split.dropFirst().joined(separator: " ")
if let prefix = split.first {
var container = AttributeContainer()
switch prefix {
case "#":
container.font = .title
case "##":
container.font = .title2
case "###":
container.font = .title3
default:
continue
}
attributed = AttributedString(unprefixed, attributes: container)
} else {
attributed = AttributedString(line + "\n\n")
}
text = text + attributed
}
return text
}
return text
}
}