diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df78850..d1d0b46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,12 @@ jobs: HOST_PROFILE_DATA: ${{ secrets.HOST_PROFILE_DATA }} AGENT_PROFILE_DATA: ${{ secrets.AGENT_PROFILE_DATA }} run: ./.github/scripts/signing.sh + - name: Update Build Number + env: + TAG_NAME: ${{ github.ref }} + run: | + export CLEAN_TAG=$(echo $TAG_NAME | sed -e 's/v//') + sed -i '' -e "s/CI_VERSION = 0.0.0/CI_VERSION = $CLEAN_TAG/g" Config/Config.xcconfig - name: Build run: xcrun xcodebuild -project Secretive.xcodeproj -scheme Secretive -configuration Release -archivePath Archive.xcarchive archive - name: Create ZIPs diff --git a/Config/Config.xcconfig b/Config/Config.xcconfig new file mode 100644 index 0000000..b265fbc --- /dev/null +++ b/Config/Config.xcconfig @@ -0,0 +1 @@ +CI_VERSION = 0.0.0 \ No newline at end of file diff --git a/SecretAgent/Info.plist b/SecretAgent/Info.plist index 1716cbe..7ad766c 100644 --- a/SecretAgent/Info.plist +++ b/SecretAgent/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - $(MARKETING_VERSION) + $(CI_VERSION) CFBundleVersion 1 LSMinimumSystemVersion diff --git a/SecretAgentKit/Info.plist b/SecretAgentKit/Info.plist index d5ff04c..28ef30f 100644 --- a/SecretAgentKit/Info.plist +++ b/SecretAgentKit/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0 + $(CI_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) NSHumanReadableCopyright diff --git a/SecretKit/Info.plist b/SecretKit/Info.plist index d5ff04c..28ef30f 100644 --- a/SecretKit/Info.plist +++ b/SecretKit/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0 + $(CI_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) NSHumanReadableCopyright diff --git a/Secretive.xcodeproj/project.pbxproj b/Secretive.xcodeproj/project.pbxproj index ab19e88..b87464c 100644 --- a/Secretive.xcodeproj/project.pbxproj +++ b/Secretive.xcodeproj/project.pbxproj @@ -30,6 +30,9 @@ 506838A12415EA5600F55094 /* AnySecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506838A02415EA5600F55094 /* AnySecret.swift */; }; 506838A32415EA5D00F55094 /* AnySecretStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506838A22415EA5D00F55094 /* AnySecretStore.swift */; }; 506AB87E2412334700335D91 /* SecretAgent.app in CopyFiles */ = {isa = PBXBuildFile; fileRef = 50A3B78A24026B7500D209EA /* SecretAgent.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 50731666241DF8660023809E /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50731665241DF8660023809E /* Updater.swift */; }; + 50731669241E00C20023809E /* NoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50731668241E00C20023809E /* NoticeView.swift */; }; + 508A58AA241E06B40069DC07 /* PreviewUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508A58A9241E06B40069DC07 /* PreviewUpdater.swift */; }; 5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; }; 5099A02723FE34FA0062B6F2 /* SmartCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02623FE34FA0062B6F2 /* SmartCard.swift */; }; 5099A02923FE35240062B6F2 /* SmartCardStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02823FE35240062B6F2 /* SmartCardStore.swift */; }; @@ -179,6 +182,10 @@ 5068389D241471CD00F55094 /* SecretStoreList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretStoreList.swift; sourceTree = ""; }; 506838A02415EA5600F55094 /* AnySecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnySecret.swift; sourceTree = ""; }; 506838A22415EA5D00F55094 /* AnySecretStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnySecretStore.swift; sourceTree = ""; }; + 50731665241DF8660023809E /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; }; + 50731668241E00C20023809E /* NoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeView.swift; sourceTree = ""; }; + 508A58A9241E06B40069DC07 /* PreviewUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewUpdater.swift; sourceTree = ""; }; + 508A58AB241E121B0069DC07 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSecretView.swift; sourceTree = ""; }; 5099A02623FE34FA0062B6F2 /* SmartCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartCard.swift; sourceTree = ""; }; 5099A02823FE35240062B6F2 /* SmartCardStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartCardStore.swift; sourceTree = ""; }; @@ -274,6 +281,7 @@ 50A3B78B24026B7500D209EA /* SecretAgent */, 5099A06D240242BA0062B6F2 /* SecretAgentKit */, 5099A07A240242BA0062B6F2 /* SecretAgentKitTests */, + 508A58AF241E144C0069DC07 /* Config */, 50617D8023FCE48E0099B055 /* Products */, 5099A08B240243730062B6F2 /* Frameworks */, ); @@ -298,11 +306,13 @@ children = ( 50617D8223FCE48E0099B055 /* AppDelegate.swift */, 50617D8423FCE48E0099B055 /* ContentView.swift */, + 50731668241E00C20023809E /* NoticeView.swift */, 50C385A42407A76D00AF2719 /* SecretDetailView.swift */, 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */, 50B8550C24138C4F009958AC /* DeleteSecretView.swift */, 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */, 50C385A8240B636500AF2719 /* SetupView.swift */, + 50731665241DF8660023809E /* Updater.swift */, 50617D8623FCE48E0099B055 /* Assets.xcassets */, 50617D8B23FCE48E0099B055 /* Main.storyboard */, 50617D8E23FCE48E0099B055 /* Info.plist */, @@ -317,6 +327,7 @@ children = ( 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */, 50617DD123FCEFA90099B055 /* PreviewStore.swift */, + 508A58A9241E06B40069DC07 /* PreviewUpdater.swift */, ); path = "Preview Content"; sourceTree = ""; @@ -381,6 +392,14 @@ path = OpenSSH; sourceTree = ""; }; + 508A58AF241E144C0069DC07 /* Config */ = { + isa = PBXGroup; + children = ( + 508A58AB241E121B0069DC07 /* Config.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; 5099A02523FE34DE0062B6F2 /* SmartCard */ = { isa = PBXGroup; children = ( @@ -735,9 +754,12 @@ 50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */, 50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */, 5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */, + 50731666241DF8660023809E /* Updater.swift in Sources */, 50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */, 50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */, + 50731669241E00C20023809E /* NoticeView.swift in Sources */, 50617D8323FCE48E0099B055 /* AppDelegate.swift in Sources */, + 508A58AA241E06B40069DC07 /* PreviewUpdater.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -871,6 +893,7 @@ /* Begin XCBuildConfiguration section */ 50617D9B23FCE48E0099B055 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 508A58AB241E121B0069DC07 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -931,6 +954,7 @@ }; 50617D9C23FCE48E0099B055 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 508A58AB241E121B0069DC07 /* Config.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -991,6 +1015,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\""; DEVELOPMENT_TEAM = Z72PRUAWF6; ENABLE_HARDENED_RUNTIME = YES; @@ -1001,6 +1026,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.15; + MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1017,6 +1043,7 @@ CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Secretive/Preview Content\""; DEVELOPMENT_TEAM = Z72PRUAWF6; ENABLE_HARDENED_RUNTIME = YES; @@ -1027,6 +1054,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.15; + MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.Host; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "Secretive - Host"; @@ -1293,7 +1321,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -1318,7 +1346,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 1; PRODUCT_BUNDLE_IDENTIFIER = com.maxgoedjen.Secretive.SecretAgent; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "Secretive - Secret Agent"; diff --git a/Secretive/AppDelegate.swift b/Secretive/AppDelegate.swift index 300b805..2997b80 100644 --- a/Secretive/AppDelegate.swift +++ b/Secretive/AppDelegate.swift @@ -13,10 +13,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { list.add(store: SmartCard.Store()) return list }() + let updater = PreviewUpdater() func applicationDidFinishLaunching(_ aNotification: Notification) { - let contentView = ContentView(storeList: storeList) + let contentView = ContentView(storeList: storeList, updater: updater) // Create the window and set the content view. window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), diff --git a/Secretive/ContentView.swift b/Secretive/ContentView.swift index bab54b9..c6e7489 100644 --- a/Secretive/ContentView.swift +++ b/Secretive/ContentView.swift @@ -1,38 +1,44 @@ import SwiftUI import SecretKit -struct ContentView: View { +struct ContentView: View { @ObservedObject var storeList: SecretStoreList + @ObservedObject var updater: UpdaterType @State fileprivate var active: AnySecret.ID? @State fileprivate var showingDeletion = false @State fileprivate var deletingSecret: AnySecret? var body: some View { - NavigationView { - List(selection: $active) { - ForEach(storeList.stores) { store in - if store.isAvailable { - Section(header: Text(store.name)) { - if store.secrets.isEmpty { - if store is AnySecretStoreModifiable { - NavigationLink(destination: EmptyStoreModifiableView(), tag: Constants.emptyStoreModifiableTag, selection: self.$active) { - Text("No Secrets") + VStack { + if updater.update != nil { + updateNotice() + } + NavigationView { + List(selection: $active) { + ForEach(storeList.stores) { store in + if store.isAvailable { + Section(header: Text(store.name)) { + if store.secrets.isEmpty { + if store is AnySecretStoreModifiable { + NavigationLink(destination: EmptyStoreModifiableView(), tag: Constants.emptyStoreModifiableTag, selection: self.$active) { + Text("No Secrets") + } + } else { + NavigationLink(destination: EmptyStoreView(), tag: Constants.emptyStoreTag, selection: self.$active) { + Text("No Secrets") + } } } else { - NavigationLink(destination: EmptyStoreView(), tag: Constants.emptyStoreTag, selection: self.$active) { - Text("No Secrets") - } - } - } else { - ForEach(store.secrets) { secret in - NavigationLink(destination: SecretDetailView(secret: secret), tag: secret.id, selection: self.$active) { - Text(secret.name) - }.contextMenu { - if store is AnySecretStoreModifiable { - Button(action: { self.delete(secret: secret) }) { - Text("Delete") + ForEach(store.secrets) { secret in + NavigationLink(destination: SecretDetailView(secret: secret), tag: secret.id, selection: self.$active) { + Text(secret.name) + }.contextMenu { + if store is AnySecretStoreModifiable { + Button(action: { self.delete(secret: secret) }) { + Text("Delete") + } } } } @@ -40,31 +46,45 @@ struct ContentView: View { } } } + }.onAppear { + let fallback: AnyHashable + if self.storeList.modifiableStore?.isAvailable ?? false { + fallback = Constants.emptyStoreModifiableTag + } else { + fallback = Constants.emptyStoreTag + } + self.active = self.storeList.stores.compactMap { $0.secrets.first }.first?.id ?? fallback } - }.onAppear { - let fallback: AnyHashable - if self.storeList.modifiableStore?.isAvailable ?? false { - fallback = Constants.emptyStoreModifiableTag - } else { - fallback = Constants.emptyStoreTag - } - self.active = self.storeList.stores.compactMap { $0.secrets.first }.first?.id ?? fallback + .listStyle(SidebarListStyle()) + .frame(minWidth: 100, idealWidth: 240) } - .listStyle(SidebarListStyle()) - .frame(minWidth: 100, idealWidth: 240) - } - .navigationViewStyle(DoubleColumnNavigationViewStyle()) - .sheet(isPresented: $showingDeletion) { - if self.storeList.modifiableStore != nil { - DeleteSecretView(secret: self.deletingSecret!, store: self.storeList.modifiableStore!) { - self.showingDeletion = false + .navigationViewStyle(DoubleColumnNavigationViewStyle()) + .sheet(isPresented: $showingDeletion) { + if self.storeList.modifiableStore != nil { + DeleteSecretView(secret: self.deletingSecret!, store: self.storeList.modifiableStore!) { + self.showingDeletion = false + } } } } - } - - + + func updateNotice() -> some View { + guard let update = updater.update else { return AnyView(Spacer()) } + let severity: NoticeView.Severity + let text: String + if update.critical { + severity = .critical + text = "Critical Security Update Required" + } else { + severity = .advisory + text = "Update Available" + } + return AnyView(NoticeView(text: text, severity: severity, actionTitle: "Update") { + NSWorkspace.shared.open(update.html_url) + }) + } + func delete(secret: SecretType) { deletingSecret = AnySecret(secret) self.showingDeletion = true @@ -72,24 +92,23 @@ struct ContentView: View { } -extension ContentView { - - enum Constants { - static let emptyStoreModifiableTag: AnyHashable = "emptyStoreModifiableTag" - static let emptyStoreTag: AnyHashable = "emptyStoreModifiableTag" - } - +fileprivate enum Constants { + static let emptyStoreModifiableTag: AnyHashable = "emptyStoreModifiableTag" + static let emptyStoreTag: AnyHashable = "emptyStoreModifiableTag" } + #if DEBUG struct ContentView_Previews: PreviewProvider { static var previews: some View { Group { - ContentView(storeList: Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)])) - ContentView(storeList: Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()])) - ContentView(storeList: Preview.storeList(stores: [Preview.Store()])) - ContentView(storeList: Preview.storeList(modifiableStores: [Preview.StoreModifiable()])) + ContentView(storeList: Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]), updater: PreviewUpdater()) + ContentView(storeList: Preview.storeList(stores: [Preview.Store()], modifiableStores: [Preview.StoreModifiable()]), updater: PreviewUpdater()) + ContentView(storeList: Preview.storeList(stores: [Preview.Store()]), updater: PreviewUpdater()) + ContentView(storeList: Preview.storeList(modifiableStores: [Preview.StoreModifiable()]), updater: PreviewUpdater()) + ContentView(storeList: Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]), updater: PreviewUpdater(update: .advisory)) + ContentView(storeList: Preview.storeList(stores: [Preview.Store(numberOfRandomSecrets: 0)], modifiableStores: [Preview.StoreModifiable(numberOfRandomSecrets: 0)]), updater: PreviewUpdater(update: .critical)) } } } diff --git a/Secretive/Info.plist b/Secretive/Info.plist index e594e4f..14b20d7 100644 --- a/Secretive/Info.plist +++ b/Secretive/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0 + $(CI_VERSION) CFBundleVersion - 1 + $(CI_VERSION) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright diff --git a/Secretive/NoticeView.swift b/Secretive/NoticeView.swift new file mode 100644 index 0000000..dceba20 --- /dev/null +++ b/Secretive/NoticeView.swift @@ -0,0 +1,57 @@ +import Foundation +import SwiftUI + +struct NoticeView: View { + + let text: String + let severity: Severity + let actionTitle: String? + let action: (() -> Void)? + + var body: some View { + HStack { + Text(text).bold() + Spacer() + if action != nil { + Button(action: action!) { + Text(actionTitle!) + } + } + }.padding().background(color) + } + + var color: Color { + switch severity { + case .advisory: + return Color.orange + case .critical: + return Color.red + } + } + +} + +extension NoticeView { + + enum Severity { + case advisory, critical + } + +} + +#if DEBUG + +struct NoticeView_Previews: PreviewProvider { + static var previews: some View { + Group { + NoticeView(text: "Agent Not Running", severity: .advisory, actionTitle: "Run Setup") { + print("OK") + } + NoticeView(text: "Critical Security Update Required", severity: .critical, actionTitle: "Update") { + print("OK") + } + } + } +} + +#endif diff --git a/Secretive/Preview Content/PreviewUpdater.swift b/Secretive/Preview Content/PreviewUpdater.swift new file mode 100644 index 0000000..8cbeda8 --- /dev/null +++ b/Secretive/Preview Content/PreviewUpdater.swift @@ -0,0 +1,27 @@ +import Foundation +import Combine + +class PreviewUpdater: UpdaterProtocol { + + let update: Release? + + init(update: Update = .none) { + switch update { + case .none: + self.update = nil + case .advisory: + self.update = Release(name: "10.10.10", html_url: URL(string: "https://example.com")!, body: "Some regular update") + case .critical: + self.update = Release(name: "10.10.10", html_url: URL(string: "https://example.com")!, body: "Critical Security Update") + } + } + +} + +extension PreviewUpdater { + + enum Update { + case none, advisory, critical + } + +} diff --git a/Secretive/Secretive.entitlements b/Secretive/Secretive.entitlements index 82ba56c..8776520 100644 --- a/Secretive/Secretive.entitlements +++ b/Secretive/Secretive.entitlements @@ -6,6 +6,8 @@ com.apple.security.smartcard + com.apple.security.network.client + keychain-access-groups $(AppIdentifierPrefix)com.maxgoedjen.Secretive diff --git a/Secretive/Updater.swift b/Secretive/Updater.swift new file mode 100644 index 0000000..25e361b --- /dev/null +++ b/Secretive/Updater.swift @@ -0,0 +1,82 @@ +import Foundation +import Combine + +protocol UpdaterProtocol: ObservableObject { + + var update: Release? { get } + +} + +class Updater: ObservableObject, UpdaterProtocol { + + @Published var update: Release? + + init() { + checkForUpdates() + let timer = Timer.scheduledTimer(withTimeInterval: 60*60*24, repeats: true) { _ in + self.checkForUpdates() + } + timer.tolerance = 60*60 + } + + func checkForUpdates() { + URLSession.shared.dataTask(with: Constants.updateURL) { data, _, _ in + guard let data = data else { return } + guard let release = try? JSONDecoder().decode(Release.self, from: data) else { return } + self.evaluate(release: release) + }.resume() + } + + func evaluate(release: Release) { + let latestVersion = semVer(from: release.name) + let currentVersion = semVer(from: Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String) + for (latest, current) in zip(latestVersion, currentVersion) { + if latest > current { + DispatchQueue.main.async { + self.update = release + } + return + } + } + } + + func semVer(from stringVersion: String) -> [Int] { + var split = stringVersion.split(separator: ".").compactMap { Int($0) } + while split.count < 3 { + split.append(0) + } + return split + } + +} + +extension Updater { + + enum Constants { + static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases/latest")! + } + +} + +struct Release: Codable { + let name: String + let html_url: URL + let body: String +} + + +extension Release { + + var critical: Bool { + return body.contains(Constants.securityContent) + } + +} + +extension Release { + + enum Constants { + static let securityContent = "Critical Security Update" + } + +}