├── .gitattributes ├── AppIcon.icon ├── Assets │ ├── Background.png │ ├── Mask Group.png │ └── Triangle.svg └── icon.json ├── Shared ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── app-icon-16.png │ │ ├── app-icon-32.png │ │ ├── app-icon-16@2x.png │ │ ├── app-icon-32@2x.png │ │ ├── app-icon-1024@1x.png │ │ ├── app-icon-dark-1024@1x.png │ │ ├── zeitgeist-macos-1024w.png │ │ ├── zeitgeist-macos-128w.png │ │ ├── zeitgeist-macos-256w.png │ │ ├── zeitgeist-macos-512w.png │ │ ├── app-icon-tinted-1024@1x.png │ │ ├── zeitgeist-macos-256w 1.png │ │ ├── zeitgeist-macos-512w 1.png │ │ └── Contents.json │ ├── StaticAppIcon.imageset │ │ ├── app-icon-60@2x.png │ │ ├── app-icon-60@3x.png │ │ └── Contents.json │ ├── github.symbolset │ │ └── Contents.json │ ├── gitlab.symbolset │ │ └── Contents.json │ ├── hook.symbolset │ │ └── Contents.json │ ├── bitbucket.symbolset │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Helpers │ ├── NavigationHelpers.swift │ ├── Pasteboard.swift │ ├── PostalService.swift │ ├── MigrationHelpers.swift │ ├── NotificationManager.swift │ ├── KeychainItem.swift │ ├── Preferences.swift │ └── PlaceholderData.swift ├── Extensions │ ├── URL.extension.swift │ ├── Notification+ZPS.swift │ ├── Date+RawRepresentable.swift │ ├── Dictionary.swift │ ├── Sequence+asyncMethods.swift │ ├── Array.extension.swift │ ├── View+permissionRevocationDialog.swift │ ├── View+dataTask.swift │ └── UIDevice+deviceInfo.swift ├── Helper Views │ ├── SignOutButton.swift │ ├── BackportCloseButton.swift │ ├── VercelUserAvatarView.swift │ ├── StatusBannerView.swift │ ├── LabelView.swift │ ├── LatestEventMenuBarLabel.swift │ ├── ZeitgeistLogo.swift │ └── PlaceholderView.swift ├── Models │ ├── VercelAliasModel.swift │ ├── VercelDomain.swift │ ├── VercelSession+accountCRUD.swift │ ├── SignInViewModel.swift │ ├── VercelProjectModel.swift │ ├── VercelSession.swift │ ├── VercelAccountModel.swift │ ├── GitRepoModel.swift │ ├── GitCommit.swift │ └── VercelDeployment.swift ├── Views │ ├── Accounts │ │ └── AccountListRowView.swift │ ├── Deployments │ │ ├── DeploymentStateIndicator.swift │ │ ├── DeploymentFilterView.swift │ │ ├── DeploymentListRowView.swift │ │ └── DeploymentLogView.swift │ ├── Projects │ │ ├── ProjectsListRowView.swift │ │ ├── ProjectNotificationsView.swift │ │ ├── ProjectsListView.swift │ │ └── ProjectDetailView.swift │ ├── Settings │ │ ├── NotificationPreview.swift │ │ └── SettingsView.swift │ ├── Onboarding │ │ ├── OnboardingView.swift │ │ └── NewFeaturesView.swift │ └── Environment Variables │ │ ├── ProjectEnvironmentVariablesView.swift │ │ ├── EnvironmentVariableEditView.swift │ │ └── EnvironmentVariableRowView.swift ├── Info.plist ├── ZeitgeistApp.swift ├── ContentView.swift ├── Networking │ └── VercelAPI.swift ├── AuthenticatedContentView.swift └── AppDelegate.swift ├── ZeitgeistWidgets ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── WidgetBackground.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── ZeitgeistWidgets.swift ├── ZeitgeistWidgetsExtension.entitlements ├── DeploymentLiveActivity.swift ├── Models │ ├── LatestDeploymentEntry.swift │ └── RecentDeploymentsEntry.swift ├── Info.plist ├── Views │ ├── WidgetLabel.swift │ ├── LatestDeploymentWidgetView.swift │ └── RecentDeploymentsWidgetView.swift ├── RecentDeploymentsWidget.swift └── LatestDeploymentWidget.swift ├── Zeitgeist.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── Zeitgeist.xcscheme │ ├── SelectWidgetAccountIntent.xcscheme │ └── ZeitgeistWidgetsExtension.xcscheme ├── SelectWidgetAccountIntent ├── SelectWidgetAccountIntent.entitlements ├── Info.plist └── IntentHandler.swift ├── Zeitgeist └── Zeitgeist.entitlements ├── README.md ├── .swiftlint.yml └── .gitignore /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj diff merge 2 | -------------------------------------------------------------------------------- /AppIcon.icon/Assets/Background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/AppIcon.icon/Assets/Background.png -------------------------------------------------------------------------------- /AppIcon.icon/Assets/Mask Group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/AppIcon.icon/Assets/Mask Group.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/app-icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/app-icon-16.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/app-icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/app-icon-32.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/app-icon-16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/app-icon-16@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/app-icon-32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/app-icon-32@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/app-icon-1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/app-icon-1024@1x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/StaticAppIcon.imageset/app-icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/StaticAppIcon.imageset/app-icon-60@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/StaticAppIcon.imageset/app-icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/StaticAppIcon.imageset/app-icon-60@3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/app-icon-dark-1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/app-icon-dark-1024@1x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-1024w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-1024w.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-128w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-128w.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-256w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-256w.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-512w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-512w.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/app-icon-tinted-1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/app-icon-tinted-1024@1x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-256w 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-256w 1.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-512w 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daneden/Zeitgeist/HEAD/Shared/Assets.xcassets/AppIcon.appiconset/zeitgeist-macos-512w 1.png -------------------------------------------------------------------------------- /Zeitgeist.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/github.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "custom.github.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/gitlab.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "custom.gitlab.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/hook.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "custom.hook.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/bitbucket.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "custom.bitbucket.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Zeitgeist.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Shared/Helpers/NavigationHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationHelpers.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 04/09/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | enum DetailDestinationValue: Hashable { 11 | case project(id: VercelProject.ID, account: VercelAccount) 12 | case deployment(id: VercelDeployment.ID, account: VercelAccount) 13 | } 14 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/ZeitgeistWidgets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZeitgeistWidgets.swift 3 | // ZeitgeistWidgets 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | @main 12 | struct ZeitgeistWidgets: WidgetBundle { 13 | @WidgetBundleBuilder 14 | var body: some Widget { 15 | LatestDeploymentWidget() 16 | RecentDeploymentsWidget() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Shared/Extensions/URL.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL.extension.swift 3 | // ZeitgeistWidgetsExtension 4 | // 5 | // Created by Daniel Eden on 03/06/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URL { 11 | static var AppStoreURL = URL(string: "https://apps.apple.com/us/app/zeitgeist/id1526052028")! 12 | static var ReviewURL = URL(string: AppStoreURL.absoluteString.appending("?action=write-review"))! 13 | } 14 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/StaticAppIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "app-icon-60@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "app-icon-60@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Zeitgeist.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "cdcc958cfe6c6dbc4b95b38cfc43b36f3fe0486a5f7f24c29c6672dde47bf70b", 3 | "pins" : [ 4 | { 5 | "identity" : "suite", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/daneden/Suite", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "13cd0bedf813f14f26787862818aef29ca13a7e5" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Shared/Helper Views/SignOutButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignOutButton.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 30/11/2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SignOutButton: View { 11 | @EnvironmentObject var session: VercelSession 12 | 13 | var body: some View { 14 | Button { 15 | #if !EXTENSION 16 | VercelSession.deleteAccount(id: session.account.id) 17 | #endif 18 | } label: { 19 | Label("Sign out", systemImage: "person.badge.minus") 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Shared/Extensions/Notification+ZPS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notification+ZPS.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 15/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Notification.Name { 11 | static var ZPSNotification = Notification.Name("ZPSNotification") 12 | 13 | static var VercelAccountAddedNotification = Notification.Name("VercelAccountAddedNotification") 14 | static var VercelAccountWillBeRemovedNotification = Notification.Name("VercelAccountWillBeRemovedNotification") 15 | } 16 | -------------------------------------------------------------------------------- /Shared/Extensions/Date+RawRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+RawRepresentable.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 19/06/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Allows dates to be stored in AppStorage 11 | extension Date: @retroactive RawRepresentable { 12 | public var rawValue: String { 13 | self.timeIntervalSinceReferenceDate.description 14 | } 15 | 16 | public init?(rawValue: String) { 17 | self = Date(timeIntervalSinceReferenceDate: Double(rawValue) ?? 0.0) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/ZeitgeistWidgetsExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.me.daneden.Zeitgeist 8 | 9 | keychain-access-groups 10 | 11 | $(AppIdentifierPrefix)me.daneden.Zeitgeist 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /SelectWidgetAccountIntent/SelectWidgetAccountIntent.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.me.daneden.Zeitgeist 8 | 9 | keychain-access-groups 10 | 11 | $(AppIdentifierPrefix)me.daneden.Zeitgeist 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Shared/Models/VercelAliasModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VercelAliasModel.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 12/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | struct VercelAlias: Codable, Hashable { 11 | var uid: String 12 | var alias: String 13 | var url: URL { 14 | URL(string: "https://\(alias)")! 15 | } 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case uid, alias 19 | } 20 | } 21 | 22 | extension VercelAlias { 23 | struct APIResponse: Codable { 24 | var aliases: [VercelAlias] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/DeploymentLiveActivity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeploymentLiveActivity.swift 3 | // ZeitgeistWidgetsExtension 4 | // 5 | // Created by Daniel Eden on 13/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | import ActivityKit 11 | 12 | struct DeploymentAttributes: ActivityAttributes { 13 | struct ContentState: Codable & Hashable { 14 | let deploymentState: VercelDeployment.State 15 | } 16 | 17 | let deploymentId: VercelDeployment.ID 18 | let deploymentCause: VercelDeployment.DeploymentCause 19 | let deploymentProject: String 20 | } 21 | -------------------------------------------------------------------------------- /Shared/Helpers/Pasteboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Pasteboard.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 07/08/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | #if os(macOS) 11 | import AppKit 12 | #elseif os(iOS) 13 | import UIKit 14 | #endif 15 | 16 | struct Pasteboard { 17 | static func setString(_ value: String?) { 18 | #if os(macOS) 19 | guard let value = value else { 20 | return 21 | } 22 | 23 | NSPasteboard.general.setString(value, forType: .string) 24 | #elseif os(iOS) 25 | UIPasteboard.general.string = value 26 | #endif 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Shared/Extensions/Dictionary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dictionary.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 05/06/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension CharacterSet { 11 | static let urlQueryValueAllowed: CharacterSet = { 12 | let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4 13 | let subDelimitersToEncode = "!$&'()*+,;=" 14 | 15 | var allowed = CharacterSet.urlQueryAllowed 16 | allowed.remove(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") 17 | return allowed 18 | }() 19 | } 20 | -------------------------------------------------------------------------------- /Shared/Helper Views/BackportCloseButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackportCloseButton.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 26/08/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BackportCloseButton: View { 11 | let action: () -> Void 12 | 13 | var body: some View { 14 | if #available(iOS 26, macOS 26, visionOS 26, watchOS 26, *) { 15 | Button(role: .close) { 16 | action() 17 | } 18 | } else { 19 | Button("Close", systemImage: "xmark") { 20 | action() 21 | } 22 | } 23 | } 24 | } 25 | 26 | #Preview { 27 | BackportCloseButton { 28 | 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Zeitgeist/Zeitgeist.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.aps-environment 8 | development 9 | com.apple.security.application-groups 10 | 11 | group.me.daneden.Zeitgeist 12 | 13 | keychain-access-groups 14 | 15 | $(AppIdentifierPrefix)me.daneden.Zeitgeist 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Shared/Extensions/Sequence+asyncMethods.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence+asyncMethods.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 07/01/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Sequence { 11 | func asyncMap( 12 | _ transform: (Element) async throws -> T 13 | ) async rethrows -> [T] { 14 | var values = [T]() 15 | 16 | for element in self { 17 | try await values.append(transform(element)) 18 | } 19 | 20 | return values 21 | } 22 | } 23 | 24 | extension Sequence { 25 | func asyncForEach( 26 | _ operation: (Element) async throws -> Void 27 | ) async rethrows { 28 | for element in self { 29 | try await operation(element) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Shared/Views/Accounts/AccountListRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountListRowView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 14/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AccountListRowView: View { 11 | var account: VercelAccount 12 | 13 | var size: Double { 14 | #if os(macOS) 15 | 20 16 | #else 17 | 24 18 | #endif 19 | } 20 | 21 | var body: some View { 22 | Label { 23 | Text(verbatim: account.name ?? account.username) 24 | } icon: { 25 | VercelUserAvatarView(account: account, size: size) 26 | } 27 | .contextMenu { 28 | Button(role: .destructive) { 29 | VercelSession.deleteAccount(id: account.id) 30 | } label: { 31 | Label("Sign out", systemImage: "person.badge.minus") 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zeitgeist 2 | > 👁 An iOS and iPadOS app for keeping an eye on your Vercel deployments 3 | 4 | Welcome to the Zeitgeist repository! Zeitgeist is an iOS and iPadOS client for browsing your [Vercel](https://vercel.com) projects and deployments. It’s built using SwiftUI and takes advantage of the iOS 15 and 16 SDKs to offer: 5 | 6 | - Real-time updates via background push notifications 7 | - Refreshable views 8 | - Server-side filtering of deployments 9 | - Lock Screen and Home Screen widgets 10 | 11 | ## Development 12 | I have a full-time job and a young child, so development of Zeitgeist can sometimes be slow! If you're interested in contributing, the best way to help me out is by opening issues detailing any bugs you encounter or features you'd like to see added to the app. 13 | -------------------------------------------------------------------------------- /Shared/Models/VercelDomain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VercelDomain.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 20/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | struct VercelDomain: Codable { 11 | let name: String 12 | let apexName: String 13 | let projectId: String 14 | let redirect: String? 15 | let redirectStatusCode: Int? 16 | let gitBranch: String? 17 | let updatedAt: Int? 18 | let createdAt: Int? 19 | let verified: Bool 20 | let verification: [Verification]? 21 | } 22 | 23 | extension VercelDomain { 24 | struct Verification: Codable { 25 | let type: String 26 | let domain: String 27 | let value: String 28 | let reason: String 29 | } 30 | 31 | struct APIResponse: Codable { 32 | let domains: [VercelDomain] 33 | let pagination: Pagination? 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_whitespace 3 | - identifier_name 4 | - todo 5 | 6 | opt_in_rules: 7 | - empty_count 8 | - empty_string 9 | 10 | excluded: 11 | - Carthage 12 | - Pods 13 | - SwiftLint/Common/3rdPartyLib 14 | 15 | line_length: 16 | warning: 150 17 | error: 200 18 | ignores_function_declarations: true 19 | ignores_comments: true 20 | ignores_urls: true 21 | 22 | function_body_length: 23 | warning: 300 24 | error: 500 25 | 26 | function_parameter_count: 27 | warning: 6 28 | error: 8 29 | 30 | type_body_length: 31 | warning: 300 32 | error: 500 33 | 34 | file_length: 35 | warning: 1000 36 | error: 1500 37 | ignore_comment_only_lines: true 38 | 39 | cyclomatic_complexity: 40 | warning: 15 41 | error: 25 42 | 43 | reporter: "xcode" 44 | -------------------------------------------------------------------------------- /Shared/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | CFBundleURLSchemes 11 | 12 | zeitgeist 13 | 14 | 15 | 16 | ITSAppUsesNonExemptEncryption 17 | 18 | NSUserActivityTypes 19 | 20 | SelectAccountIntent 21 | 22 | UIApplicationSceneManifest 23 | 24 | UIApplicationSupportsMultipleScenes 25 | 26 | 27 | UIBackgroundModes 28 | 29 | remote-notification 30 | 31 | UILaunchScreen 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Models/LatestDeploymentEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LatestDeploymentEntry.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // Updated by Brad Bergeron on 22/11/2023. 7 | // 8 | 9 | import Foundation 10 | import WidgetKit 11 | 12 | // MARK: - LatestDeploymentEntry 13 | 14 | struct LatestDeploymentEntry: TimelineEntry { 15 | let date = Date() 16 | var deployment: VercelDeployment? 17 | var account: WidgetAccount 18 | var project: WidgetProject? 19 | var relevance: TimelineEntryRelevance? 20 | } 21 | 22 | #if DEBUG 23 | 24 | extension LatestDeploymentEntry { 25 | static var mockNoAccount = LatestDeploymentEntry( 26 | account: WidgetAccount(identifier: nil, display: "No Account")) 27 | 28 | static var mockExample = LatestDeploymentEntry( 29 | deployment: VercelProject.exampleData.targets!.production!, 30 | account: WidgetAccount(identifier: "1", display: "Test Account"), 31 | project: WidgetProject(identifier: "1", display: "example-project")) 32 | } 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /Shared/ZeitgeistApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZeitgesitApp.swift 3 | // Verdant 4 | // 5 | // Created by Daniel Eden on 29/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ZeitgeistApp: App { 12 | #if !os(macOS) 13 | @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 14 | #else 15 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 16 | #endif 17 | 18 | var body: some Scene { 19 | WindowGroup { 20 | ContentView() 21 | .task { 22 | if MigrationHelpers.V3.needsMigration { 23 | await MigrationHelpers.V3.migrateAccountIdsToAccounts() 24 | } 25 | } 26 | } 27 | 28 | #if os(macOS) 29 | Settings { 30 | SettingsView() 31 | .formStyle(.grouped) 32 | } 33 | #endif 34 | } 35 | } 36 | 37 | extension ZeitgeistApp { 38 | static var appVersion: String { 39 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" 40 | } 41 | 42 | static var majorAppVersion: String { 43 | String(appVersion.first ?? "0") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Shared/Extensions/Array.extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array.extension.swift 3 | // Verdant 4 | // 5 | // Created by Daniel Eden on 29/05/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array where Element: Hashable { 11 | mutating func toggleElement(_ element: Element, inArray: Bool) { 12 | if inArray { 13 | append(element) 14 | } else { 15 | removeAll { $0 == element } 16 | } 17 | } 18 | 19 | func contains(_ element: Element) -> Bool { 20 | contains { $0 == element } 21 | } 22 | } 23 | 24 | extension Array: @retroactive RawRepresentable where Element: Codable { 25 | public init?(rawValue: String) { 26 | guard let data = rawValue.data(using: .utf8), 27 | let result = try? JSONDecoder().decode([Element].self, from: data) 28 | else { 29 | return nil 30 | } 31 | self = result 32 | } 33 | 34 | public var rawValue: String { 35 | guard let data = try? JSONEncoder().encode(self), 36 | let result = String(data: data, encoding: .utf8) 37 | else { 38 | return "[]" 39 | } 40 | return result 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Models/RecentDeploymentsEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecentDeploymentsEntry.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // Updated by Brad Bergeron on 22/11/2023. 7 | // 8 | 9 | import Foundation 10 | import WidgetKit 11 | 12 | // MARK: - RecentDeploymentsEntry 13 | 14 | struct RecentDeploymentsEntry: TimelineEntry { 15 | let date = Date() 16 | var deployments: [VercelDeployment]? 17 | var account: WidgetAccount 18 | var project: WidgetProject? 19 | var relevance: TimelineEntryRelevance? 20 | } 21 | 22 | #if DEBUG 23 | 24 | extension RecentDeploymentsEntry { 25 | static var mockNoAccount = RecentDeploymentsEntry( 26 | account: WidgetAccount(identifier: nil, display: "No Account")) 27 | 28 | static var mockExample = RecentDeploymentsEntry( 29 | deployments: Array(repeating: VercelProject.exampleData.targets!.production!, count: 12), 30 | account: WidgetAccount(identifier: "1", display: "Test Account"), 31 | project: WidgetProject(identifier: "1", display: "example-project")) 32 | } 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "systemIndigoColor" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "ios", 19 | "reference" : "systemIndigoColor" 20 | }, 21 | "idiom" : "universal" 22 | }, 23 | { 24 | "color" : { 25 | "platform" : "osx", 26 | "reference" : "systemIndigoColor" 27 | }, 28 | "idiom" : "mac" 29 | }, 30 | { 31 | "appearances" : [ 32 | { 33 | "appearance" : "luminosity", 34 | "value" : "dark" 35 | } 36 | ], 37 | "color" : { 38 | "platform" : "osx", 39 | "reference" : "systemIndigoColor" 40 | }, 41 | "idiom" : "mac" 42 | } 43 | ], 44 | "info" : { 45 | "author" : "xcode", 46 | "version" : 1 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Shared/Helper Views/VercelUserAvatarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VercelUserAvatarView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 21/12/2020. 6 | // Copyright © 2020 Daniel Eden. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct VercelUserAvatarView: View { 12 | var account: VercelAccount? 13 | 14 | var avatarID: String? { account?.avatar } 15 | 16 | @State var size: CGFloat = 32 17 | 18 | private var url: String { 19 | return "https://vercel.com/api/www/avatar/\(avatarID ?? "")?s=\(size)" 20 | } 21 | 22 | var body: some View { 23 | AsyncImage(url: URL(string: url), scale: 2) { image in 24 | image 25 | .resizable() 26 | .scaledToFit() 27 | .clipShape(Circle()) 28 | .overlay( 29 | Circle() 30 | .stroke(Color.primary.opacity(0.1), lineWidth: 1) 31 | ) 32 | } placeholder: { 33 | Image(systemName: "person.crop.circle.fill") 34 | .resizable() 35 | .scaledToFit() 36 | .foregroundStyle(.tint) 37 | } 38 | .frame(width: size, height: size) 39 | } 40 | } 41 | 42 | struct UserAvatar_Previews: PreviewProvider { 43 | static var previews: some View { 44 | VercelUserAvatarView(account: nil) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSSupportsLiveActivities 6 | 7 | NSSupportsLiveActivitiesFrequentUpdates 8 | 9 | CFBundleDevelopmentRegion 10 | $(DEVELOPMENT_LANGUAGE) 11 | CFBundleDisplayName 12 | ZeitgeistWidgets 13 | CFBundleExecutable 14 | $(EXECUTABLE_NAME) 15 | CFBundleIdentifier 16 | $(PRODUCT_BUNDLE_IDENTIFIER) 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleName 20 | $(PRODUCT_NAME) 21 | CFBundlePackageType 22 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 23 | CFBundleShortVersionString 24 | $(MARKETING_VERSION) 25 | CFBundleVersion 26 | $(CURRENT_PROJECT_VERSION) 27 | NSExtension 28 | 29 | NSExtensionPointIdentifier 30 | com.apple.widgetkit-extension 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Shared/Helper Views/StatusBannerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusBannerView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 22/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StatusBannerView: View { 11 | var states: [VercelDeployment.State] = [.building, .ready, .queued, .error] 12 | 13 | var body: some View { 14 | VStack(spacing: 16) { 15 | ForEach(0..<12, id: \.self) { _ in 16 | HStack(spacing: 16) { 17 | ForEach(0..<6, id: \.self) { i in 18 | let state = states[Int.random(in: 0...(states.count-1))] 19 | 20 | if Int.random(in: 0...4).isMultiple(of: 3) { 21 | DeploymentStateIndicator(state: state, style: .compact).fixedSize() 22 | } else { 23 | DeploymentStateIndicator(state: state).fixedSize() 24 | } 25 | } 26 | } 27 | } 28 | } 29 | .symbolVariant(.fill) 30 | .symbolRenderingMode(.hierarchical) 31 | .mask { 32 | LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom) 33 | } 34 | .opacity(0.5) 35 | .rotationEffect(.degrees(-5)) 36 | .accessibilityHidden(true) 37 | } 38 | } 39 | 40 | struct StatusBannerView_Previews: PreviewProvider { 41 | static var previews: some View { 42 | StatusBannerView() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Views/WidgetLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetLabel.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // Updated by Brad Bergeron on 22/11/2023. 7 | // 8 | 9 | import SwiftUI 10 | 11 | // MARK: - WidgetLabel 12 | 13 | struct WidgetLabel: View { 14 | let label: String 15 | let iconName: String 16 | 17 | var body: some View { 18 | Label(label, systemImage: iconName) 19 | .labelStyle(WidgetLabelStyle()) 20 | .widgetAccentable() 21 | } 22 | } 23 | 24 | // MARK: - WidgetLabelStyle 25 | 26 | private struct WidgetLabelStyle: LabelStyle { 27 | 28 | // MARK: Internal 29 | 30 | func makeBody(configuration: Configuration) -> some View { 31 | HStack(alignment: .center, spacing: 4) { 32 | configuration.icon 33 | configuration.title 34 | } 35 | .font(.system(size: 13, weight: .medium)) 36 | .padding(.horizontal, showsWidgetContainerBackground ? 4 : 0) 37 | .padding(.vertical, showsWidgetContainerBackground ? 2 : 0) 38 | .background(showsWidgetContainerBackground ? AnyShapeStyle(.quaternary.opacity(0.5)) : AnyShapeStyle(.clear)) 39 | .clipShape(ContainerRelativeShape()) 40 | } 41 | 42 | // MARK: Private 43 | 44 | @Environment(\.showsWidgetContainerBackground) private var showsWidgetContainerBackground 45 | 46 | } 47 | -------------------------------------------------------------------------------- /Shared/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 17/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | @AppStorage(Preferences.authenticatedAccounts) var accounts 12 | @AppStorage(Preferences.lastAppVersionOpened) private var lastAppVersionOpened 13 | 14 | @State private var presentNewFeaturesScreen = false 15 | @State private var presentOnboardingView = false 16 | 17 | var body: some View { 18 | AuthenticatedContentView() 19 | .formStyle(.grouped) 20 | .animation(.default, value: accounts.isEmpty) 21 | .symbolRenderingMode(.hierarchical) 22 | .onAppear { 23 | if let lastAppVersionOpened, 24 | lastAppVersionOpened == "2" && ZeitgeistApp.majorAppVersion == "3" { 25 | presentNewFeaturesScreen = true 26 | self.lastAppVersionOpened = ZeitgeistApp.majorAppVersion 27 | } 28 | } 29 | .sheet(isPresented: $presentNewFeaturesScreen) { 30 | NewFeaturesView() 31 | } 32 | .task(id: accounts.hashValue) { 33 | presentOnboardingView = accounts.isEmpty 34 | } 35 | .sheet(isPresented: $presentOnboardingView) { 36 | OnboardingView() 37 | .interactiveDismissDisabled() 38 | #if !os(iOS) 39 | .frame(minWidth: 800, minHeight: 600) 40 | #endif 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Shared/Extensions/View+permissionRevocationDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+permissionRevokactionDialog.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 25/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PermissionRevocationDialogModifier: ViewModifier { 11 | @ObservedObject var session: VercelSession 12 | @State var isVisible = false 13 | func body(content: Content) -> some View { 14 | content 15 | .onAppear { 16 | isVisible = session.requestsDenied 17 | } 18 | .onChange(of: session.requestsDenied) { _ in 19 | isVisible = session.requestsDenied 20 | } 21 | .confirmationDialog( 22 | "Account permissions revoked", 23 | isPresented: $isVisible) { 24 | Button(role: .destructive) { 25 | VercelSession.deleteAccount(id: session.account.id) 26 | } label: { 27 | Text("Remove account") 28 | } 29 | 30 | Button(role: .cancel) { 31 | isVisible = false 32 | } label: { 33 | Text("Close") 34 | } 35 | } message: { 36 | Text("There was a problem loading data for this account. It may have been deleted, or its access token may have been revoked.") 37 | } 38 | } 39 | } 40 | 41 | extension View { 42 | func permissionRevocationDialog(session: VercelSession) -> some View { 43 | modifier(PermissionRevocationDialogModifier(session: session)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Shared/Helpers/PostalService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostalService.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 05/06/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ZPSError: Error { 11 | case FieldCastingError(field: Any?) 12 | case EventTypeCastingError(eventType: Any?) 13 | } 14 | 15 | enum ZPSEventType: String { 16 | case deployment 17 | case deploymentReady = "deployment-ready" 18 | case deploymentError = "deployment-error" 19 | case projectCreated = "project-created" 20 | case projectRemoved = "project-removed" 21 | 22 | var emojiPrefix: String { 23 | switch self { 24 | case .deployment: 25 | return "⏱ " 26 | case .deploymentReady: 27 | return "✅ " 28 | case .deploymentError: 29 | return "🛑 " 30 | case .projectCreated: 31 | return "📂 " 32 | case .projectRemoved: 33 | return "🗑 " 34 | } 35 | } 36 | 37 | var associatedState: VercelDeployment.State? { 38 | switch self { 39 | case .deployment: 40 | return .building 41 | case .deploymentReady: 42 | return .ready 43 | case .deploymentError: 44 | return .error 45 | default: 46 | return nil 47 | } 48 | } 49 | } 50 | 51 | struct ZPSNotificationPayload: Hashable { 52 | let deploymentId: String 53 | let userId: String 54 | let title: String? 55 | let body: String 56 | let category: ZPSEventType 57 | } 58 | 59 | enum ZPSNotificationCategory: String { 60 | case deployment, project 61 | } 62 | -------------------------------------------------------------------------------- /Shared/Views/Deployments/DeploymentStateIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeploymentStateIndicator.swift 3 | // Verdant 4 | // 5 | // Created by Daniel Eden on 30/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum StateIndicatorStyle { 11 | case normal, compact 12 | } 13 | 14 | struct DeploymentStateIndicator: View { 15 | var state: VercelDeployment.State 16 | var style: StateIndicatorStyle = .normal 17 | 18 | var label: LocalizedStringKey { 19 | state.description 20 | } 21 | 22 | var color: Color { 23 | state.color 24 | } 25 | 26 | var iconName: String { 27 | state.imageName 28 | } 29 | 30 | var body: some View { 31 | return Group { 32 | if style == .normal { 33 | Label(label, systemImage: iconName) 34 | } else { 35 | Label(label, systemImage: iconName) 36 | .labelStyle(.iconOnly) 37 | } 38 | } 39 | .foregroundStyle(color) 40 | .symbolVariant(.fill) 41 | .symbolRenderingMode(.hierarchical) 42 | .widgetAccentable() 43 | } 44 | } 45 | 46 | struct DeploymentStateIndicator_Previews: PreviewProvider { 47 | static var previews: some View { 48 | Group { 49 | ForEach(VercelDeployment.State.allCases, id: \.self) { state in 50 | DeploymentStateIndicator(state: state) 51 | } 52 | 53 | ForEach(VercelDeployment.State.allCases, id: \.self) { state in 54 | DeploymentStateIndicator(state: state, style: .compact) 55 | } 56 | }.previewLayout(.sizeThatFits) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Shared/Helper Views/LabelView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeploymentDetailLabel.swift 3 | // Verdant 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | fileprivate struct ValueLabelStyle: LabelStyle { 11 | func makeBody(configuration: Configuration) -> some View { 12 | HStack { 13 | configuration.icon 14 | configuration.title 15 | } 16 | } 17 | } 18 | 19 | struct LabelView: View { 20 | var label: S 21 | var content: Content 22 | 23 | init(_ label: @escaping () -> S, @ViewBuilder content: () -> Content) { 24 | self.label = label() 25 | self.content = content() 26 | } 27 | 28 | var body: some View { 29 | LabeledContent { 30 | content 31 | .labelStyle(ValueLabelStyle()) 32 | } label: { 33 | label 34 | .alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in 35 | dimension[.listRowSeparatorLeading] 36 | }) 37 | } 38 | } 39 | } 40 | 41 | extension LabelView where S == Text { 42 | init(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) { 43 | self.label = Text(label) 44 | self.content = content() 45 | } 46 | 47 | init(_ label: Text, @ViewBuilder content: () -> Content) { 48 | self.label = label 49 | self.content = content() 50 | } 51 | } 52 | 53 | struct DeploymentDetailLabel_Previews: PreviewProvider { 54 | static var previews: some View { 55 | LabelView("Label") { 56 | Text("Value") 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Shared/Helpers/MigrationHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MigrationHelpers.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 17/08/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct MigrationHelpers { 12 | struct V3 { 13 | @AppStorage(Preferences.authenticatedAccountIds) static var authenticatedAccountIds 14 | @AppStorage(Preferences.authenticatedAccounts) static var authenticatedAccounts 15 | @AppStorage(Preferences.lastAppVersionOpened) static private var lastAppVersionOpened 16 | 17 | static var needsMigration: Bool { 18 | !authenticatedAccountIds.isEmpty 19 | } 20 | 21 | static func migrateAccountIdsToAccounts() async { 22 | if needsMigration { 23 | lastAppVersionOpened = "2" 24 | } 25 | 26 | let accounts: [VercelAccount] = await Array(Set(authenticatedAccountIds)).asyncMap { id in 27 | guard let token = KeychainItem(account: id).wrappedValue else { 28 | return nil 29 | } 30 | 31 | do { 32 | var request = VercelAPI.request(for: .account(id: id), with: id) 33 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") 34 | 35 | let (data, _) = try await URLSession.shared.data(for: request) 36 | 37 | return try JSONDecoder().decode(VercelAccount.self, from: data) 38 | } catch { 39 | print(error) 40 | return nil 41 | } 42 | 43 | }.compactMap { $0 } 44 | 45 | authenticatedAccounts += accounts 46 | authenticatedAccountIds = [] 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Shared/Helper Views/LatestEventMenuBarLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LatestEventMenuBarLabel.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 21/06/2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LatestEventMenuBarLabel: View { 11 | @State private var latestEvent: VercelDeployment.State? 12 | @State private var lastReceivedEvent = Date.now 13 | 14 | let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 15 | 16 | var body: some View { 17 | Label { 18 | Text(verbatim: "Zeitgeist") 19 | } icon: { 20 | Image(systemName: latestEvent?.imageName ?? VercelDeployment.State.normal.imageName) 21 | } 22 | .symbolVariant(.fill) 23 | .symbolRenderingMode(.hierarchical) 24 | .onReceive(NotificationCenter.default.publisher(for: .ZPSNotification)) { notificationPayload in 25 | guard let userInfo = notificationPayload.userInfo, 26 | let eventTypeString = userInfo["eventType"] as? String, 27 | let eventType: ZPSEventType = ZPSEventType(rawValue: eventTypeString) else { 28 | return 29 | } 30 | 31 | withAnimation { 32 | latestEvent = eventType.associatedState 33 | lastReceivedEvent = .now 34 | } 35 | } 36 | .onReceive(timer) { _ in 37 | if latestEvent != nil && abs(lastReceivedEvent.distance(to: .now)) > 60 { 38 | latestEvent = nil 39 | } 40 | } 41 | } 42 | } 43 | 44 | struct LatestEventMenuBarLabel_Previews: PreviewProvider { 45 | static var previews: some View { 46 | LatestEventMenuBarLabel() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /SelectWidgetAccountIntent/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | SelectWidgetAccountIntent 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSExtension 24 | 25 | NSExtensionAttributes 26 | 27 | IntentsRestrictedWhileLocked 28 | 29 | IntentsRestrictedWhileProtectedDataUnavailable 30 | 31 | IntentsSupported 32 | 33 | SelectAccountIntent 34 | 35 | 36 | NSExtensionPointIdentifier 37 | com.apple.intents-service 38 | NSExtensionPrincipalClass 39 | $(PRODUCT_MODULE_NAME).IntentHandler 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Shared/Extensions/View+dataTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+dataTask.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 16/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DataTaskModifier: ViewModifier { 11 | @EnvironmentObject var session: VercelSession 12 | @Environment(\.scenePhase) var scenePhase 13 | let action: () async -> Void 14 | 15 | func body(content: Content) -> some View { 16 | content 17 | .task { 18 | print("Updating from .task modifier") 19 | await action() 20 | } 21 | .refreshable { 22 | print("Updating due to refresh") 23 | await action() 24 | } 25 | .onReceive(NotificationCenter.default.publisher(for: .ZPSNotification)) { content in 26 | print("Updating based on background notification") 27 | Task { await action() } 28 | } 29 | .onReceive(session.objectWillChange) { _ in 30 | print("Updating based on change in session") 31 | if session.isAuthenticated { 32 | Task { await action() } 33 | } else { 34 | print("Skipping dataTask since the session is no longer authenticated") 35 | } 36 | } 37 | .onChange(of: scenePhase) { currentScenePhase in 38 | if case .active = currentScenePhase { 39 | print("Updating based on scenePhase") 40 | Task { await action() } 41 | } 42 | } 43 | } 44 | } 45 | 46 | extension View { 47 | func dataTask(perform action: @escaping () async -> Void) -> some View { 48 | modifier(DataTaskModifier(action: action)) 49 | } 50 | } 51 | 52 | extension DataTaskModifier { 53 | static func postNotification(_ userInfo: [AnyHashable: Any]? = nil) { 54 | NotificationCenter.default.post(name: .ZPSNotification, object: nil, userInfo: userInfo) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Shared/Views/Deployments/DeploymentFilterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeploymentFilterView.swift 3 | // Verdant 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DeploymentFilter: Codable, Hashable { 11 | var state: VercelDeployment.State? 12 | var productionOnly = false 13 | 14 | var filtersApplied: Bool { 15 | state != nil || productionOnly 16 | } 17 | 18 | var urlQueryItems: [URLQueryItem] { 19 | var queryItems: [URLQueryItem] = [] 20 | if let state = state { 21 | queryItems.append(URLQueryItem(name: "state", value: state.rawValue)) 22 | } 23 | 24 | if productionOnly { 25 | queryItems.append(URLQueryItem(name: "target", value: "production")) 26 | } 27 | 28 | return queryItems 29 | } 30 | } 31 | 32 | struct DeploymentFilterView: View { 33 | @Binding var filter: DeploymentFilter 34 | 35 | var body: some View { 36 | Section("Filter deployments by:") { 37 | Picker("Status", selection: $filter.state.animation()) { 38 | Text("All statuses").tag(Optional(nil)) 39 | 40 | ForEach(VercelDeployment.State.typicalCases, id: \.self) { state in 41 | DeploymentStateIndicator(state: state) 42 | .tag(Optional(state)) 43 | } 44 | }.accentColor(.secondary) 45 | 46 | Toggle(isOn: $filter.productionOnly.animation()) { 47 | Label("Production deployments only", systemImage: "theatermasks") 48 | .symbolVariant(filter.productionOnly ? .fill : .none) 49 | } 50 | } 51 | 52 | Button(action: { 53 | withAnimation { 54 | self.filter = .init() 55 | } 56 | }, label: { 57 | Text("Clear filters") 58 | }) 59 | .disabled(!filter.filtersApplied) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /AppIcon.icon/Assets/Triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Shared/Views/Deployments/DeploymentListRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeploymentListRowView.swift 3 | // Verdant 4 | // 5 | // Created by Daniel Eden on 30/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DeploymentListRowView: View { 11 | var deployment: VercelDeployment 12 | var projectName: String? 13 | 14 | var body: some View { 15 | return Label { 16 | VStack(alignment: .leading) { 17 | HStack(spacing: 4) { 18 | if deployment.target == .production { 19 | Label("Production deployment", systemImage: "theatermasks") 20 | .labelStyle(.iconOnly) 21 | .foregroundStyle(.tint) 22 | .symbolVariant(.fill) 23 | .imageScale(.small) 24 | } 25 | 26 | Text(deployment.project) 27 | } 28 | .font(.footnote.bold()) 29 | 30 | switch deployment.deploymentCause { 31 | case let .deployHook(name): 32 | Text("\(Image(deployment.deploymentCause.icon!)) \(name)", comment: "Label for a deployment caused by a deploy hook ({icon} {name})") 33 | .lineLimit(2) 34 | .imageScale(.small) 35 | case .promotion(_): 36 | Text("\(Image(systemName: "arrow.up.circle")) Production rebuild", comment: "Label for a deployment caused by a promotion to production") 37 | .lineLimit(2) 38 | .imageScale(.small) 39 | default: 40 | Text(deployment.deploymentCause.description) 41 | .lineLimit(2) 42 | } 43 | 44 | VStack(alignment: .leading, spacing: 2) { 45 | Text("\(deployment.created, style: .relative) ago", comment: "Timestamp for when a deployment was created in a deployment list row") 46 | .fixedSize() 47 | .foregroundStyle(.secondary) 48 | .font(.caption) 49 | } 50 | } 51 | } icon: { 52 | DeploymentStateIndicator(state: deployment.state, style: .compact) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Shared/Helpers/NotificationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationManager.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 05/06/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import UserNotifications 11 | 12 | class NotificationManager { 13 | static let shared = NotificationManager() 14 | private let notificationCenter = UNUserNotificationCenter.current() 15 | 16 | static func requestAuthorization() async throws -> Bool { 17 | return try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) 18 | } 19 | 20 | @AppStorage(Preferences.deploymentNotificationIds) 21 | static var deploymentNotificationIds 22 | 23 | @AppStorage(Preferences.deploymentErrorNotificationIds) 24 | static var deploymentErrorNotificationIds 25 | 26 | @AppStorage(Preferences.deploymentReadyNotificationIds) 27 | static var deploymentReadyNotificationIds 28 | 29 | @AppStorage(Preferences.deploymentNotificationsProductionOnly) 30 | static var deploymentNotificationsProductionOnly 31 | 32 | static func userAllowedNotifications(for eventType: ZPSEventType, 33 | with projectId: VercelProject.ID, 34 | target: VercelDeployment.Target? = nil) -> Bool 35 | { 36 | if deploymentNotificationsProductionOnly.contains(projectId), target != .production { 37 | return false 38 | } 39 | 40 | switch eventType { 41 | case .deployment: 42 | return deploymentNotificationIds.contains(projectId) 43 | case .deploymentError: 44 | return deploymentErrorNotificationIds.contains(projectId) 45 | case .deploymentReady: 46 | return deploymentReadyNotificationIds.contains(projectId) 47 | default: 48 | // TODO: Add proper handling for event notifications and migrate to notifications based on project subscriptions 49 | return true 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /SelectWidgetAccountIntent/IntentHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntentHandler.swift 3 | // SelectWidgetAccountIntent 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // 7 | 8 | import Intents 9 | 10 | class IntentHandler: INExtension, SelectAccountIntentHandling { 11 | func provideProjectOptionsCollection(for intent: SelectAccountIntent) async throws -> INObjectCollection { 12 | guard let account = intent.account, 13 | let account = Preferences.accounts.first(where: { $0.id == account.identifier }) else { 14 | return .init(items: []) 15 | } 16 | 17 | let session = VercelSession(account: account) 18 | var request = VercelAPI.request(for: .projects(), with: account.id, queryItems: [URLQueryItem(name: "limit", value: "100")]) 19 | try session.signRequest(&request) 20 | 21 | let (data, _) = try await URLSession.shared.data(for: request) 22 | let decoded = try JSONDecoder().decode(VercelProject.APIResponse.self, from: data) 23 | return .init(items: [WidgetProject(identifier: nil, display: "All Projects")] + decoded.projects.map { WidgetProject(identifier: $0.id, display: $0.name) }) 24 | } 25 | 26 | func provideAccountOptionsCollection(for _: SelectAccountIntent) async throws -> INObjectCollection { 27 | let accounts = Preferences.accounts 28 | .map { account in 29 | WidgetAccount(identifier: account.id, display: account.name ?? account.username) 30 | } 31 | 32 | return INObjectCollection(items: accounts) 33 | } 34 | 35 | override func handler(for _: INIntent) -> Any { 36 | return self 37 | } 38 | 39 | func defaultAccount(for _: SelectAccountIntent) -> WidgetAccount? { 40 | guard let firstAccount = Preferences.accounts.first else { 41 | return nil 42 | } 43 | 44 | return WidgetAccount( 45 | identifier: firstAccount.id, 46 | display: firstAccount.name ?? firstAccount.username 47 | ) 48 | } 49 | 50 | func defaultProject(for intent: SelectAccountIntent) -> WidgetProject? { 51 | return WidgetProject(identifier: nil, display: "All Projects") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Shared/Models/VercelSession+accountCRUD.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VercelSession+accountCRUD.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 23/09/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | extension VercelSession { 12 | static func addAccount(id: String, token: String) async { 13 | guard id != .NullValue else { return } 14 | 15 | KeychainItem(account: id).wrappedValue = token 16 | 17 | let urlString = "https://api.vercel.com/v2/\(id.isTeam ? "teams/\(id)?teamId=\(id)" : "user")" 18 | var request = URLRequest(url: URL(string: urlString)!) 19 | 20 | do { 21 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") 22 | let (data, _) = try await URLSession.shared.data(for: request) 23 | let decoded = try JSONDecoder().decode(VercelAccount.self, from: data) 24 | 25 | DispatchQueue.main.async { [decoded] in 26 | withAnimation { 27 | if let index = Preferences.accounts.firstIndex(where: { $0.id == decoded.id }) { 28 | Preferences.accounts[index] = decoded 29 | } else { 30 | Preferences.accounts.append(decoded) 31 | } 32 | } 33 | 34 | NotificationCenter.default.post(Notification(name: .VercelAccountAddedNotification)) 35 | } 36 | 37 | #if os(iOS) 38 | await UIApplication.shared.registerForRemoteNotifications() 39 | #elseif os(macOS) 40 | await NSApplication.shared.registerForRemoteNotifications() 41 | #endif 42 | } catch { 43 | print("Encountered an error when adding account with ID \(id)") 44 | print(error) 45 | } 46 | } 47 | 48 | static func deleteAccount(id: String) { 49 | let keychain = KeychainItem(account: id) 50 | keychain.wrappedValue = nil 51 | 52 | guard let accountIndex = Preferences.accounts.firstIndex(where: { $0.id == id }) else { 53 | return 54 | } 55 | 56 | NotificationCenter.default.post(name: .VercelAccountWillBeRemovedNotification, object: accountIndex) 57 | 58 | withAnimation { 59 | _ = Preferences.accounts.remove(at: accountIndex) 60 | } 61 | } 62 | } 63 | 64 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Shared/Views/Projects/ProjectsListRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProjectsListRowView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 07/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProjectsListRowView: View { 11 | @AppStorage(Preferences.projectSummaryDisplayOption) var projectSummaryDisplayOption 12 | 13 | var project: VercelProject 14 | 15 | var deploymentSummarySource: VercelDeployment? { 16 | switch projectSummaryDisplayOption { 17 | case .latestDeployment: 18 | return project.latestDeployments?.first 19 | case .productionDeployment: 20 | return project.targets?.production 21 | } 22 | } 23 | 24 | var body: some View { 25 | VStack(alignment: .leading, spacing: 4) { 26 | HStack(alignment: .firstTextBaseline) { 27 | Text(project.name) 28 | .font(.headline) 29 | Spacer() 30 | Text(project.updated ?? project.created, style: .relative) 31 | .foregroundStyle(.secondary) 32 | .font(.caption) 33 | } 34 | 35 | if let productionDeploymentCause = deploymentSummarySource?.deploymentCause { 36 | if case .deployHook(_) = productionDeploymentCause, 37 | let icon = productionDeploymentCause.icon { 38 | Text("\(Image(icon)) \(productionDeploymentCause.description)", comment: "The cause of a deployment in the projects list") 39 | .lineLimit(2) 40 | } else { 41 | Text(productionDeploymentCause.description).lineLimit(2) 42 | .contentTransition(.numericText()) 43 | .animation(.default, value: projectSummaryDisplayOption) 44 | } 45 | } 46 | 47 | if let repoSlug = project.link?.repoSlug, 48 | let provider = project.link?.type 49 | { 50 | Text("\(Image(provider.rawValue)) \(repoSlug)", comment: "Icon and name of a repository for a project in the projects list") 51 | .font(.footnote) 52 | .foregroundStyle(.secondary) 53 | } 54 | } 55 | .padding(.vertical, 4) 56 | } 57 | } 58 | 59 | struct ProjectsListRowView_Previews: PreviewProvider { 60 | static var previews: some View { 61 | ProjectsListRowView(project: .exampleData) 62 | .previewLayout(.sizeThatFits) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Shared/Views/Settings/NotificationPreview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationPreviews.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 23/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NotificationPreview: View { 11 | var eventType: ZPSEventType = .deployment 12 | var projectName = "my-project" 13 | var description = "Caused by \(Preferences.accounts.first?.username ?? "daneden")’s commit \"Initial commit\"" 14 | var showsEmoji = false 15 | 16 | private var title: String { 17 | switch eventType { 18 | case .deployment: 19 | return "\(emoji)New build started for \(projectName)" 20 | case .deploymentReady: 21 | return "\(emoji)Deployment ready for \(projectName)" 22 | case .deploymentError: 23 | return "\(emoji)Build failed for \(projectName)" 24 | case .projectCreated: 25 | return "\(emoji)Project Created" 26 | case .projectRemoved: 27 | return "\(emoji)Project Removed" 28 | } 29 | } 30 | 31 | private var emoji: String { 32 | guard showsEmoji else { return "" } 33 | 34 | return eventType.emojiPrefix 35 | } 36 | 37 | var body: some View { 38 | HStack { 39 | Image("StaticAppIcon") 40 | .resizable() 41 | .frame(width: 28, height: 28) 42 | .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) 43 | .padding(.trailing, 8) 44 | 45 | VStack(alignment: .leading) { 46 | Text(title) 47 | .font(.subheadline.bold()) 48 | Text(description) 49 | .font(.subheadline) 50 | } 51 | 52 | Spacer(minLength: 0) 53 | } 54 | .padding() 55 | .background(.thinMaterial) 56 | .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) 57 | } 58 | } 59 | 60 | struct NotificationPreviews_Previews: PreviewProvider { 61 | static var previews: some View { 62 | Group { 63 | NotificationPreview() 64 | NotificationPreview(eventType: .deploymentError) 65 | 66 | NotificationPreview(showsEmoji: true) 67 | NotificationPreview(eventType: .deploymentError, showsEmoji: true) 68 | } 69 | .padding() 70 | .previewLayout(.sizeThatFits) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Shared/Helper Views/ZeitgeistLogo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZeitgeistLogo.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 22/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | fileprivate let zeitgeistLogoColors = [ 11 | Color(red: 0.34, green: 0, blue: 0.78), 12 | Color(red: 0.11, green: 0.37, blue: 0.92), 13 | Color(red: 0, green: 0.56, blue: 0.97), 14 | Color(red: 0, green: 0.61, blue: 0.95), 15 | Color(red: 0, green: 0.4, blue: 0.91), 16 | Color(red: 0.39, green: 0.33, blue: 0.89), 17 | Color(red: 0.76, green: 0.31, blue: 0.86), 18 | Color(red: 1, green: 0.33, blue: 0.71), 19 | Color(red: 1, green: 0.39, blue: 0.43), 20 | Color(red: 1, green: 0.54, blue: 0.19), 21 | Color(red: 1, green: 0.38, blue: 0.44), 22 | Color(red: 0.87, green: 0.25, blue: 0.81), 23 | Color(red: 0.34, green: 0, blue: 0.78), 24 | ] 25 | 26 | struct ZeitgeistLogo: View { 27 | @ScaledMetric var size = 128 28 | @State private var appear = false 29 | 30 | @ViewBuilder 31 | var clipShape: RoundedRectangle { 32 | RoundedRectangle(cornerRadius: size * 0.25, style: .continuous) 33 | } 34 | 35 | var body: some View { 36 | ZStack { 37 | AngularGradient( 38 | colors: zeitgeistLogoColors, 39 | center: .center 40 | ) 41 | .scaleEffect(1.5) 42 | .blur(radius: size * 0.1) 43 | .rotationEffect(Angle(degrees: (appear ? 360 : 0) - 120), anchor: .center) 44 | .task { 45 | withAnimation(.linear(duration: 30).repeatForever(autoreverses: false)) { 46 | appear.toggle() 47 | } 48 | } 49 | 50 | Image(systemName: "triangle.fill") 51 | .resizable() 52 | .scaledToFit() 53 | .padding() 54 | .padding() 55 | .foregroundColor(.white) 56 | .shadow(color: .black.opacity(0.3), radius: size * 0.2, x: 0, y: size * 0.1) 57 | } 58 | .clipShape(clipShape) 59 | .overlay { 60 | clipShape 61 | .strokeBorder(Color.primary.opacity(0.2), style: .init()) 62 | .blendMode(.plusLighter) 63 | } 64 | .frame(width: size, height: size) 65 | .shadow( 66 | color: .black.opacity(0.1), 67 | radius: size * 0.2, 68 | x: 0, 69 | y: size * 0.1 70 | ) 71 | } 72 | } 73 | 74 | #Preview { 75 | ZeitgeistLogo() 76 | } 77 | -------------------------------------------------------------------------------- /Shared/Helper Views/PlaceholderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaceholderView.swift 3 | // Verdant 4 | // 5 | // Created by Daniel Eden on 30/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum PlaceholderRole { 11 | case DeploymentList, DeploymentDetail, NoDeployments, NoAccounts, ProjectDetail, NoProjects, NoEnvVars, AuthError 12 | } 13 | 14 | struct PlaceholderView: View { 15 | @ScaledMetric var spacing: CGFloat = 4 16 | var forRole: PlaceholderRole 17 | var alignment: HorizontalAlignment = .center 18 | 19 | var imageName: String { 20 | switch forRole { 21 | case .DeploymentDetail, .ProjectDetail: 22 | return "doc.text.magnifyingglass" 23 | case .DeploymentList: 24 | return "person.2.fill" 25 | case .NoDeployments, .NoProjects, .NoEnvVars: 26 | return "text.magnifyingglass" 27 | case .NoAccounts: 28 | return "person.3.fill" 29 | case .AuthError: 30 | return "person.crop.circle.badge.exclamationmark" 31 | } 32 | } 33 | 34 | @ViewBuilder 35 | var text: some View { 36 | switch forRole { 37 | case .ProjectDetail: 38 | Text("No project selected") 39 | case .DeploymentDetail: 40 | Text("No deployment selected") 41 | case .DeploymentList: 42 | Text("No account selected") 43 | case .NoDeployments: 44 | Text("No deployments to show") 45 | case .NoAccounts: 46 | Text("No accounts found") 47 | case .NoProjects: 48 | Text("No projects to show") 49 | case .NoEnvVars: 50 | Text("No environment variables for project") 51 | case .AuthError: 52 | VStack { 53 | Text("Error authenticating account") 54 | .font(.headline) 55 | Text("The selected account has not been authorised on this device. You can try signing out and signing in again.") 56 | SignOutButton() 57 | } 58 | } 59 | } 60 | 61 | var body: some View { 62 | VStack(alignment: alignment, spacing: spacing) { 63 | Image(systemName: imageName) 64 | .imageScale(.large) 65 | text 66 | } 67 | .multilineTextAlignment(alignment == .leading ? .leading : .center) 68 | .foregroundColor(.secondary) 69 | } 70 | } 71 | 72 | struct PlaceholderView_Previews: PreviewProvider { 73 | static var previews: some View { 74 | PlaceholderView(forRole: .DeploymentDetail) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon-1024@1x.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "app-icon-dark-1024@1x.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "app-icon-tinted-1024@1x.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | }, 33 | { 34 | "filename" : "app-icon-16.png", 35 | "idiom" : "mac", 36 | "scale" : "1x", 37 | "size" : "16x16" 38 | }, 39 | { 40 | "filename" : "app-icon-16@2x.png", 41 | "idiom" : "mac", 42 | "scale" : "2x", 43 | "size" : "16x16" 44 | }, 45 | { 46 | "filename" : "app-icon-32.png", 47 | "idiom" : "mac", 48 | "scale" : "1x", 49 | "size" : "32x32" 50 | }, 51 | { 52 | "filename" : "app-icon-32@2x.png", 53 | "idiom" : "mac", 54 | "scale" : "2x", 55 | "size" : "32x32" 56 | }, 57 | { 58 | "filename" : "zeitgeist-macos-128w.png", 59 | "idiom" : "mac", 60 | "scale" : "1x", 61 | "size" : "128x128" 62 | }, 63 | { 64 | "filename" : "zeitgeist-macos-256w 1.png", 65 | "idiom" : "mac", 66 | "scale" : "2x", 67 | "size" : "128x128" 68 | }, 69 | { 70 | "filename" : "zeitgeist-macos-256w.png", 71 | "idiom" : "mac", 72 | "scale" : "1x", 73 | "size" : "256x256" 74 | }, 75 | { 76 | "filename" : "zeitgeist-macos-512w 1.png", 77 | "idiom" : "mac", 78 | "scale" : "2x", 79 | "size" : "256x256" 80 | }, 81 | { 82 | "filename" : "zeitgeist-macos-512w.png", 83 | "idiom" : "mac", 84 | "scale" : "1x", 85 | "size" : "512x512" 86 | }, 87 | { 88 | "filename" : "zeitgeist-macos-1024w.png", 89 | "idiom" : "mac", 90 | "scale" : "2x", 91 | "size" : "512x512" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | .DS_Store 92 | -------------------------------------------------------------------------------- /Shared/Models/SignInViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInViewModel.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 01/01/2021. 6 | // Copyright © 2021 Daniel Eden. All rights reserved. 7 | // 8 | 9 | import AuthenticationServices 10 | import Combine 11 | import Foundation 12 | import SwiftUI 13 | 14 | class VercelAPIConfiguration: Codable { 15 | public let clientId: String = "oac_j50L1tLSVzBpEv1gXEVDdR3g" 16 | 17 | public enum CodingKeys: String, CodingKey { 18 | case clientId = "client_id" 19 | } 20 | } 21 | 22 | class VercelURLAuthenticationBuilder { 23 | let domain: String 24 | let clientID: String 25 | let uid = UUID() 26 | 27 | init(domain: String = "vercel.com", clientID: String) { 28 | self.domain = domain 29 | self.clientID = clientID 30 | } 31 | 32 | var url: URL { 33 | var components = URLComponents() 34 | components.scheme = "https" 35 | components.host = domain 36 | components.path = "/integrations/zeitgeist/new" 37 | components.queryItems = [ 38 | "client_id": clientID, 39 | "v": "2", 40 | ].map { URLQueryItem(name: $0, value: $1) } 41 | 42 | return components.url! 43 | } 44 | 45 | func callAsFunction() -> URL { 46 | url 47 | } 48 | } 49 | 50 | class SignInViewModel: NSObject, ObservableObject { 51 | private(set) var isSigningIn = false 52 | 53 | @MainActor 54 | func processResponseURL(url: URL) async -> Bool { 55 | let components = URLComponents(url: url, resolvingAgainstBaseURL: true) 56 | 57 | if let queryItems = components?.queryItems, 58 | let token = queryItems.filter({ $0.name == "token" }).first?.value 59 | { 60 | let teamId = queryItems.filter { $0.name == "teamId" }.first?.value ?? nil 61 | let userId = queryItems.filter { $0.name == "userId" }.first?.value ?? nil 62 | 63 | await VercelSession.addAccount(id: teamId ?? userId ?? VercelAccount.ID.NullValue, token: token) 64 | return true 65 | } else { 66 | print("Something went wrong!") 67 | return false 68 | } 69 | } 70 | 71 | @discardableResult 72 | func signIn(using webAuthenticationSession: WebAuthenticationSession) async -> Bool { 73 | self.isSigningIn = true 74 | 75 | let apiData = VercelAPIConfiguration() 76 | let authUrl = VercelURLAuthenticationBuilder(clientID: apiData.clientId)() 77 | 78 | do { 79 | let urlWithToken = try await webAuthenticationSession.authenticate(using: authUrl, 80 | callbackURLScheme: "https", 81 | preferredBrowserSession: .shared) 82 | self.isSigningIn = false 83 | return await processResponseURL(url: urlWithToken) 84 | } catch { 85 | self.isSigningIn = false 86 | print(error.localizedDescription) 87 | 88 | return false 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Shared/Networking/VercelAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VercelAPI.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 05/06/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | enum LoaderError: Error { 11 | case unknown 12 | case decodingError 13 | case unauthorized 14 | } 15 | 16 | enum LoadingStatus { 17 | case loading 18 | case empty 19 | case loaded(_ data: Resource) 20 | case error 21 | } 22 | 23 | extension LoaderError: LocalizedError { 24 | var errorDescription: String? { 25 | switch self { 26 | case .unauthorized: 27 | return "The request couldn’t be authorized. Try deleting and re-authenticating your account." 28 | default: 29 | return "An unknown error occured: \(self)" 30 | } 31 | } 32 | } 33 | 34 | enum VercelAPI { 35 | enum Path { 36 | case deployments( 37 | version: Int = 6, 38 | deploymentID: VercelDeployment.ID? = nil, 39 | path: String? = nil 40 | ) 41 | 42 | case projects(_ projectId: VercelProject.ID? = nil, path: String? = nil) 43 | 44 | case account(id: VercelAccount.ID) 45 | 46 | var subPaths: [String] { 47 | switch self { 48 | case let .deployments(_, deploymentID, path): 49 | return [deploymentID, path].compactMap { $0 } 50 | case let .projects(projectId, path): 51 | return [projectId, path].compactMap { $0 } 52 | case .account: return [] 53 | } 54 | } 55 | 56 | var resolvedPath: String { 57 | switch self { 58 | case let .deployments(version, _, _): 59 | return "v\(version)/deployments/\(subPaths.joined(separator: "/"))" 60 | case .projects: 61 | return "v9/projects/\(subPaths.joined(separator: "/"))" 62 | case let .account(id): 63 | let isTeam = id.isTeam 64 | return isTeam ? "v2/teams/\(id)" : "v2/user" 65 | } 66 | } 67 | } 68 | 69 | enum RequestMethod: String, RawRepresentable { 70 | case GET, PUT, PATCH, DELETE, POST 71 | } 72 | 73 | static func request(for path: Path, 74 | with accountID: VercelAccount.ID, 75 | queryItems: [URLQueryItem] = [], 76 | method: RequestMethod = .GET) -> URLRequest 77 | { 78 | let isTeam = accountID.isTeam 79 | var urlComponents = URLComponents(string: "https://api.vercel.com/\(path.resolvedPath)")! 80 | var completeQuery = queryItems 81 | 82 | completeQuery.append(URLQueryItem(name: "userId", value: accountID)) 83 | 84 | if isTeam { 85 | completeQuery.append(URLQueryItem(name: "teamId", value: accountID)) 86 | } 87 | 88 | urlComponents.queryItems = completeQuery 89 | 90 | var request = URLRequest(url: urlComponents.url!) 91 | request.httpMethod = method.rawValue 92 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 93 | 94 | return request 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Shared/Models/VercelProjectModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VercelProjectModel.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 08/07/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct VercelProject: Decodable, Identifiable { 12 | typealias ID = String 13 | let accountId: String 14 | let createdAt: Int 15 | let id: ID 16 | let latestDeployments: [VercelDeployment]? 17 | let name: String 18 | let targets: Targets? 19 | let updatedAt: Int? 20 | let link: VercelRepositoryLink? 21 | } 22 | 23 | extension VercelProject { 24 | var created: Date { 25 | Date(timeIntervalSince1970: TimeInterval(createdAt / 1000)) 26 | } 27 | 28 | var updated: Date? { 29 | guard let updatedAt = updatedAt else { return nil } 30 | return Date(timeIntervalSince1970: TimeInterval(updatedAt / 1000)) 31 | } 32 | } 33 | 34 | extension VercelProject { 35 | struct APIResponse: Decodable { 36 | let projects: [VercelProject] 37 | let pagination: Pagination 38 | } 39 | 40 | struct Targets: Decodable { 41 | let production: VercelDeployment? 42 | } 43 | } 44 | 45 | struct Pagination: Codable { 46 | let count: Int 47 | let prev: Int? 48 | let next: Int? 49 | } 50 | 51 | struct VercelEnv: Codable, Identifiable, Hashable { 52 | let id: String 53 | let type: EnvType 54 | let key: String 55 | let value: String 56 | let configurationId: String? 57 | let createdAt: Int 58 | let updatedAt: Int 59 | let gitBranch: String? 60 | let createdBy: String? 61 | let updatedBy: String? 62 | let decrypted: Bool? 63 | let target: [String] 64 | } 65 | 66 | extension VercelEnv { 67 | var created: Date { 68 | Date(timeIntervalSince1970: TimeInterval(createdAt / 1000)) 69 | } 70 | 71 | var updated: Date { 72 | Date(timeIntervalSince1970: TimeInterval(updatedAt / 1000)) 73 | } 74 | 75 | enum EnvType: String, Codable { 76 | case system, secret, encrypted, plain 77 | } 78 | 79 | struct APIResponse: Codable { 80 | var envs: [VercelEnv] 81 | var pagination: Pagination? 82 | } 83 | 84 | var targetsProduction: Bool { target.contains(where: { $0 == "production" }) } 85 | var targetsPreview: Bool { target.contains(where: { $0 == "preview" }) } 86 | var targetsDevelopment: Bool { target.contains(where: { $0 == "development" }) } 87 | } 88 | 89 | @available(iOS 16.0, *) 90 | extension VercelEnv: Transferable { 91 | func data() -> Data { 92 | guard let data = try? JSONEncoder().encode(self) else { 93 | return Data() 94 | } 95 | 96 | return data 97 | } 98 | 99 | static var transferRepresentation: some TransferRepresentation { 100 | DataRepresentation(exportedContentType: .json) { envVar in 101 | envVar.data() 102 | } 103 | 104 | CodableRepresentation(contentType: .json) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Shared/Views/Onboarding/OnboardingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 05/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OnboardingView: View { 11 | @State var signInModel = SignInViewModel() 12 | @Environment(\.webAuthenticationSession) private var webAuthenticationSession 13 | 14 | var body: some View { 15 | GeometryReader { geometry in 16 | ScrollView { 17 | VStack(spacing: 12) { 18 | Spacer() 19 | 20 | ZeitgeistLogo() 21 | .padding(.vertical) 22 | .frame(maxWidth: .infinity) 23 | 24 | Text("Welcome to Zeitgeist") 25 | .font(.largeTitle) 26 | .fontWeight(.bold) 27 | 28 | Text("Zeitgeist lets you see and manage your Vercel deployments.") 29 | Text("Watch builds complete, cancel or delete them, and get quick access to their URLs, logs, and commits.") 30 | .padding(.bottom) 31 | 32 | Button { 33 | Task { 34 | await signInModel.signIn(using: webAuthenticationSession) 35 | } 36 | } label: { 37 | Label { 38 | Text("Sign in with Vercel") 39 | } icon: { 40 | if signInModel.isSigningIn { 41 | ProgressView() 42 | .controlSize(.small) 43 | } else { 44 | Image(systemName: "triangle.fill") 45 | } 46 | } 47 | .frame(maxWidth: .infinity) 48 | .font(.headline) 49 | } 50 | .buttonStyle(.borderedProminent) 51 | .controlSize(.large) 52 | .frame(maxWidth: 400) 53 | .disabled(signInModel.isSigningIn) 54 | 55 | Text("To get started, sign in with your Vercel account.") 56 | .font(.caption) 57 | .foregroundColor(.secondary) 58 | 59 | Spacer() 60 | 61 | HStack { 62 | Link(destination: URL(string: "https://zeitgeist.daneden.me/privacy")!) { 63 | HStack { 64 | Spacer() 65 | Text("Privacy Policy") 66 | Spacer() 67 | } 68 | } 69 | 70 | Link(destination: URL(string: "https://zeitgeist.daneden.me/terms")!) { 71 | HStack { 72 | Spacer() 73 | Text("Terms of Use") 74 | Spacer() 75 | } 76 | } 77 | } 78 | .buttonStyle(.bordered) 79 | .frame(maxWidth: 500) 80 | } 81 | .padding() 82 | .frame(minHeight: geometry.size.height) 83 | .multilineTextAlignment(.center) 84 | } 85 | .background { 86 | ZStack(alignment: .top) { 87 | Color.clear.background(.regularMaterial).mask { 88 | LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom) 89 | } 90 | 91 | StatusBannerView() 92 | .redacted(reason: .placeholder) 93 | } 94 | .ignoresSafeArea() 95 | } 96 | } 97 | } 98 | } 99 | 100 | struct OnboardingView_Previews: PreviewProvider { 101 | static var previews: some View { 102 | OnboardingView() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Shared/Views/Onboarding/NewFeaturesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewFeaturesView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 21/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum IconType { 11 | case system, custom 12 | } 13 | 14 | struct FeatureDescription: Hashable, Identifiable { 15 | var id: Int { hashValue } 16 | var heading: String 17 | var description: String 18 | var iconName: String 19 | var iconType: IconType = .system 20 | } 21 | 22 | struct NewFeaturesView: View { 23 | @ScaledMetric var width: CGFloat = 50 24 | @Environment(\.dismiss) private var dismiss 25 | let features: [FeatureDescription] = [ 26 | FeatureDescription(heading: "Projects View", 27 | description: "Browse by projects, and quickly see their Git connections and latest deployments.", 28 | iconName: "folder"), 29 | FeatureDescription(heading: "Redeploy", 30 | description: "Redeploy instantly from a deployment's detail view, with or without the existing build cache.", 31 | iconName: "arrow.clockwise"), 32 | FeatureDescription(heading: "Notification Improvements ", 33 | description: "Manage notifications on a per-project basis, and optionally only get notifications for production deployments.", 34 | iconName: "bell.badge"), 35 | FeatureDescription(heading: "Deploy Hooks", 36 | description: "Added support for deploy hooks means at-a-glance clarity on the cause of a deployment.", 37 | iconName: "hook", 38 | iconType: .custom) 39 | ] 40 | 41 | var body: some View { 42 | GeometryReader { geometry in 43 | ScrollView { 44 | VStack(spacing: 24) { 45 | Spacer() 46 | Text("What’s new in Zeitgeist") 47 | .font(.largeTitle.bold()) 48 | .multilineTextAlignment(.center) 49 | Spacer() 50 | VStack(alignment: .leading, spacing: 24) { 51 | ForEach(features) { feature in 52 | HStack { 53 | Group { 54 | switch feature.iconType { 55 | case .system: 56 | Image(systemName: feature.iconName) 57 | case .custom: 58 | Image(feature.iconName) 59 | } 60 | } 61 | .font(.largeTitle.weight(.light)) 62 | .foregroundStyle(.tint) 63 | .frame(width: width) 64 | 65 | VStack(alignment: .leading) { 66 | Text(feature.heading).font(.headline) 67 | Text(feature.description).foregroundStyle(.secondary) 68 | } 69 | } 70 | } 71 | } 72 | 73 | Spacer() 74 | Spacer() 75 | 76 | Button { 77 | dismiss() 78 | } label: { 79 | HStack { 80 | Spacer() 81 | Text("Continue").fontWeight(.semibold) 82 | Spacer() 83 | } 84 | .padding() 85 | }.buttonStyle(.borderedProminent) 86 | } 87 | .padding() 88 | .frame(minHeight: geometry.size.height) 89 | } 90 | } 91 | } 92 | } 93 | 94 | struct NewFeaturesView_Previews: PreviewProvider { 95 | static var previews: some View { 96 | NewFeaturesView() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Zeitgeist.xcodeproj/xcshareddata/xcschemes/Zeitgeist.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Shared/Models/VercelSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Session.swift 3 | // Verdant 4 | // 5 | // Created by Daniel Eden on 29/05/2021. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | import SwiftUI 11 | 12 | enum SessionError: Error { 13 | case notAuthenticated 14 | } 15 | 16 | extension SessionError: CustomStringConvertible { 17 | var description: String { 18 | switch self { 19 | case .notAuthenticated: 20 | return "The chosen account has not been authenticated on this device" 21 | } 22 | } 23 | } 24 | 25 | extension VercelAccount { 26 | func deepEqual(to comparison: VercelAccount) -> Bool { 27 | self.id == comparison.id && 28 | self.name == comparison.name && 29 | self.username == comparison.username && 30 | self.avatar == comparison.avatar 31 | } 32 | } 33 | 34 | class VercelSession: ObservableObject { 35 | @AppStorage(Preferences.authenticatedAccounts) 36 | private var authenticatedAccounts 37 | 38 | @Published var account: VercelAccount { 39 | willSet { 40 | if newValue.id != account.id { 41 | accountLastUpdated = nil 42 | } 43 | } 44 | } 45 | 46 | private var accountLastUpdated: Date? = nil 47 | @Published private(set) var requestsDenied = false 48 | 49 | init(account: VercelAccount) { 50 | self.account = account 51 | } 52 | 53 | func refreshAccount() async { 54 | accountLastUpdated = .now 55 | let moreRecentAccount = await loadAccount() 56 | 57 | if let moreRecentAccount = moreRecentAccount, 58 | account != moreRecentAccount { 59 | self.account.updateAccount(to: moreRecentAccount) 60 | if let index = authenticatedAccounts.firstIndex(of: account) { 61 | authenticatedAccounts[index] = moreRecentAccount 62 | } 63 | } 64 | } 65 | 66 | var authenticationToken: String? { 67 | return KeychainItem(account: account.id).wrappedValue 68 | } 69 | 70 | var isAuthenticated: Bool { 71 | authenticationToken != nil 72 | } 73 | 74 | @MainActor 75 | func loadAccount() async -> VercelAccount? { 76 | do { 77 | guard authenticationToken != nil else { 78 | return nil 79 | } 80 | 81 | var request = VercelAPI.request(for: .account(id: account.id), with: account.id) 82 | try signRequest(&request) 83 | 84 | let (data, response) = try await URLSession.shared.data(for: request) 85 | 86 | validateResponse(response) 87 | 88 | return try JSONDecoder().decode(VercelAccount.self, from: data) 89 | } catch { 90 | print(error) 91 | return nil 92 | } 93 | } 94 | 95 | func validateResponse(_ response: URLResponse) { 96 | if let response = response as? HTTPURLResponse, 97 | response.statusCode == 403 { 98 | requestsDenied = true 99 | } 100 | } 101 | 102 | func signRequest(_ request: inout URLRequest) throws { 103 | guard let authenticationToken = authenticationToken else { 104 | throw SessionError.notAuthenticated 105 | } 106 | 107 | if accountLastUpdated == nil { 108 | Task { await refreshAccount() } 109 | } else if let accountLastUpdated = accountLastUpdated, 110 | accountLastUpdated.distance(to: .now) > 60 * 60 { 111 | Task { await refreshAccount() } 112 | } 113 | 114 | request.addValue("Bearer \(authenticationToken)", forHTTPHeaderField: "Authorization") 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Shared/Helpers/KeychainItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainItem.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 01/01/2021. 6 | // Copyright © 2021 Daniel Eden. All rights reserved. 7 | // swiftlint:disable all 8 | 9 | import Foundation 10 | import Security 11 | 12 | private func throwIfNotZero(_ status: OSStatus) throws { 13 | guard status != 0 else { return } 14 | throw KeychainError.keychainError(status: status) 15 | } 16 | 17 | public enum KeychainError: Error { 18 | case invalidData 19 | case keychainError(status: OSStatus) 20 | } 21 | 22 | extension Dictionary { 23 | func adding(key: Key, value: Value) -> Dictionary { 24 | var copy = self 25 | copy[key] = value 26 | return copy 27 | } 28 | } 29 | 30 | @propertyWrapper 31 | public final class KeychainItem { 32 | private let account: String 33 | private let accessGroup: String? 34 | 35 | public init(account: String) { 36 | self.account = account 37 | accessGroup = nil 38 | } 39 | 40 | public init(account: String, accessGroup: String) { 41 | self.account = account 42 | self.accessGroup = accessGroup 43 | } 44 | 45 | private var baseDictionary: [String: AnyObject] { 46 | let base = [ 47 | kSecClass as String: kSecClassGenericPassword, 48 | kSecAttrAccount as String: account as AnyObject, 49 | kSecAttrSynchronizable as String: kCFBooleanTrue!, 50 | ] 51 | 52 | return accessGroup == nil 53 | ? base 54 | : base.adding(key: kSecAttrAccessGroup as String, value: accessGroup as AnyObject) 55 | } 56 | 57 | private var query: [String: AnyObject] { 58 | return baseDictionary.adding(key: kSecMatchLimit as String, value: kSecMatchLimitOne) 59 | } 60 | 61 | public var wrappedValue: String? { 62 | get { 63 | try? read() 64 | } 65 | set { 66 | if let v = newValue { 67 | if let _ = try? read() { 68 | try! update(v) 69 | } else { 70 | try! add(v) 71 | } 72 | } else { 73 | try? delete() 74 | } 75 | } 76 | } 77 | 78 | private func delete() throws { 79 | // SecItemDelete seems to fail with errSecItemNotFound if the item does not exist in the keychain. Is this expected behavior? 80 | let status = SecItemDelete(baseDictionary as CFDictionary) 81 | guard status != errSecItemNotFound else { return } 82 | try throwIfNotZero(status) 83 | } 84 | 85 | private func read() throws -> String? { 86 | let query = self.query.adding(key: kSecReturnData as String, value: true as AnyObject) 87 | var result: AnyObject? 88 | let status = SecItemCopyMatching(query as CFDictionary, &result) 89 | guard status != errSecItemNotFound else { return nil } 90 | try throwIfNotZero(status) 91 | guard let data = result as? Data, let string = String(data: data, encoding: .utf8) else { 92 | throw KeychainError.invalidData 93 | } 94 | return string 95 | } 96 | 97 | private func update(_ secret: String) throws { 98 | let dictionary: [String: AnyObject] = [ 99 | kSecValueData as String: secret.data(using: String.Encoding.utf8)! as AnyObject, 100 | ] 101 | try throwIfNotZero(SecItemUpdate(baseDictionary as CFDictionary, dictionary as CFDictionary)) 102 | } 103 | 104 | private func add(_ secret: String) throws { 105 | let dictionary = baseDictionary.adding(key: kSecValueData as String, value: secret.data(using: .utf8)! as AnyObject) 106 | try throwIfNotZero(SecItemAdd(dictionary as CFDictionary, nil)) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Shared/AuthenticatedContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticatedContentView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 11/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Suite 10 | 11 | struct AuthenticatedContentView: View { 12 | @Environment(\.webAuthenticationSession) private var webAuthenticationSession 13 | @AppStorage(Preferences.authenticatedAccounts) private var accounts 14 | 15 | @State private var signInModel = SignInViewModel() 16 | @State private var presentSettingsView = false 17 | @State private var selectedAccount: VercelAccount? 18 | 19 | var body: some View { 20 | NavigationSplitView { 21 | List(selection: $selectedAccount) { 22 | Section { 23 | ForEach(accounts, id: \.self) { 24 | AccountListRowView(account: $0) 25 | } 26 | .onDelete(perform: deleteAccount) 27 | 28 | Button { 29 | Task { 30 | await signInModel.signIn(using: webAuthenticationSession) 31 | } 32 | } label: { 33 | Label("Add account", systemImage: "plus") 34 | .backportCircleSymbolVariant() 35 | } 36 | } header: { 37 | Text("Accounts", comment: "Header for accounts list") 38 | } 39 | } 40 | #if os(iOS) 41 | .toolbar { 42 | ToolbarItem(placement: .navigation) { 43 | Button { 44 | presentSettingsView = true 45 | } label: { 46 | Label("Settings", systemImage: "ellipsis") 47 | .backportCircleSymbolVariant() 48 | } 49 | } 50 | } 51 | .sheet(isPresented: $presentSettingsView) { 52 | NavigationView { 53 | SettingsView() 54 | .navigationBarTitleDisplayMode(.inline) 55 | } 56 | } 57 | #endif 58 | .navigationTitle(Text(verbatim: "Zeitgeist")) 59 | } content: { 60 | if let selectedAccount { 61 | NavigationStack { 62 | ProjectsListView() 63 | .environmentObject(VercelSession(account: selectedAccount)) 64 | .id(selectedAccount) 65 | } 66 | } else { 67 | Text("No account selected", comment: "Label for projects list when no account is selected") 68 | .foregroundStyle(.secondary) 69 | } 70 | } detail: { 71 | NavigationStack { 72 | PlaceholderView(forRole: .ProjectDetail) 73 | } 74 | } 75 | .task(id: accounts.first) { 76 | selectedAccount = accounts.first 77 | } 78 | .onReceive(NotificationCenter.default.publisher(for: .VercelAccountAddedNotification)) { _ in 79 | selectedAccount = accounts.last 80 | } 81 | .onReceive(NotificationCenter.default.publisher(for: .VercelAccountWillBeRemovedNotification), perform: { output in 82 | guard let index = output.object as? Int else { 83 | return 84 | } 85 | 86 | let previousAccountIndex = accounts.index(before: index) 87 | let nextAccountIndex = accounts.index(after: index) 88 | 89 | if accounts.indices.contains(previousAccountIndex) { 90 | selectedAccount = accounts[previousAccountIndex] 91 | } else if accounts.indices.contains(nextAccountIndex) { 92 | selectedAccount = accounts[nextAccountIndex] 93 | } else if accounts.indices.contains(index) { 94 | selectedAccount = accounts[index] 95 | } else { 96 | selectedAccount = nil 97 | } 98 | }) 99 | } 100 | 101 | func deleteAccount(at indices: IndexSet) { 102 | for index in indices { 103 | VercelSession.deleteAccount(id: accounts[index].id) 104 | } 105 | } 106 | } 107 | 108 | struct AuthenticatedContentView_Previews: PreviewProvider { 109 | static var previews: some View { 110 | AuthenticatedContentView() 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Shared/Models/VercelAccountModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountViewModel.swift 3 | // Verdant 4 | // 5 | // Created by Daniel Eden on 29/05/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol Account: Decodable { 11 | var id: String { get } 12 | var name: String? { get } 13 | var avatar: String? { get } 14 | var username: String { get } 15 | } 16 | 17 | struct VercelAccount: Account, Codable, Identifiable { 18 | typealias ID = String 19 | 20 | private var wrapped: Account 21 | 22 | mutating func updateAccount(to newAccount: VercelAccount) { 23 | self.wrapped = newAccount.wrapped 24 | } 25 | 26 | var id: ID { wrapped.id } 27 | var isTeam: Bool { id.isTeam } 28 | var avatar: String? { wrapped.avatar } 29 | var name: String? { wrapped.name } 30 | var username: String { wrapped.username } 31 | 32 | enum CodingKeys: String, CodingKey { 33 | case id, avatar, name, username 34 | } 35 | 36 | init(from decoder: Decoder) throws { 37 | // Accounts are encoded as direct values when encoded from VercelAccount, so we'll try decoding that first 38 | if let user = try? VercelUser(from: decoder) { 39 | wrapped = user 40 | } else 41 | // Otherwise, we may be decoding a response from /v2/user 42 | if let user = try? VercelUser.APIResponse(from: decoder).user { 43 | wrapped = user 44 | } else 45 | // Otherwise, we may be decoding a response from /v2/teams/{id} 46 | if let team = try? VercelTeam(from: decoder) { 47 | wrapped = team 48 | } else { 49 | throw DecodingError.dataCorrupted( 50 | DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to decode Vercel account.") 51 | ) 52 | } 53 | } 54 | 55 | func encode(to encoder: Encoder) throws { 56 | var container = encoder.singleValueContainer() 57 | 58 | if let team = wrapped as? VercelTeam { 59 | try container.encode(team) 60 | } else if let user = wrapped as? VercelUser { 61 | try container.encode(user) 62 | } else { 63 | throw EncodingError.invalidValue( 64 | wrapped, 65 | EncodingError.Context(codingPath: encoder.codingPath, 66 | debugDescription: "Unable to encode Vercel account") 67 | ) 68 | } 69 | } 70 | } 71 | 72 | extension VercelAccount: Hashable { 73 | static func == (lhs: VercelAccount, rhs: VercelAccount) -> Bool { 74 | lhs.hashValue == rhs.hashValue 75 | } 76 | 77 | func hash(into hasher: inout Hasher) { 78 | hasher.combine(id) 79 | hasher.combine(avatar) 80 | hasher.combine(name) 81 | hasher.combine(username) 82 | } 83 | } 84 | 85 | extension VercelAccount.ID { 86 | var isTeam: Bool { 87 | starts(with: "team_") 88 | } 89 | 90 | static var NullValue = "NULL" 91 | } 92 | 93 | private struct VercelUser: Account, Codable { 94 | var id: String 95 | var name: String? 96 | var avatar: String? 97 | var username: String 98 | } 99 | 100 | extension VercelUser { 101 | struct APIResponse: Codable { 102 | var user: VercelUser 103 | } 104 | } 105 | 106 | private struct VercelTeam: Account, Codable { 107 | var id: String 108 | var name: String? 109 | var avatar: String? 110 | var username: String 111 | 112 | enum CodingKeys: String, CodingKey { 113 | case id, name, avatar 114 | case username = "slug" 115 | } 116 | } 117 | 118 | // extension AccountViewModel { 119 | // func loadCachedData() -> VercelAccount? { 120 | // if let cachedResults = URLCache.shared.cachedResponse(for: request), 121 | // let decodedResults = handleResponseData(data: cachedResults.data, isTeam: accountId.isTeam) { 122 | // return decodedResults 123 | // } 124 | // 125 | // return nil 126 | // } 127 | // } 128 | -------------------------------------------------------------------------------- /Shared/Helpers/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct Preferences { 12 | enum Keys: String { 13 | case authenticatedAccounts, 14 | authenticatedAccountIds, 15 | lastAppVersionOpened, 16 | notificationsEnabled, 17 | deploymentNotificationsProductionOnly, 18 | deploymentReadyNotificationIds, 19 | deploymentErrorNotificationIds, 20 | deploymentNotificationIds, 21 | notificationGrouping, 22 | notificationEmoji, 23 | projectSummaryDisplayOption, 24 | lastAuthenticated, 25 | authenticationTimeout, 26 | followLogs 27 | } 28 | 29 | typealias AppStorageKVPair = (key: Keys, value: T) 30 | 31 | static let notificationsEnabled: AppStorageKVPair = (.notificationsEnabled, false) 32 | static let deploymentNotificationsProductionOnly: AppStorageKVPair<[VercelProject.ID]> = (.deploymentNotificationsProductionOnly, []) 33 | static let deploymentReadyNotificationIds: AppStorageKVPair<[VercelProject.ID]> = (.deploymentReadyNotificationIds, []) 34 | static let deploymentErrorNotificationIds: AppStorageKVPair<[VercelProject.ID]> = (.deploymentErrorNotificationIds, []) 35 | static let deploymentNotificationIds: AppStorageKVPair<[VercelProject.ID]> = (.deploymentNotificationIds, []) 36 | static let authenticatedAccounts: AppStorageKVPair<[VercelAccount]> = (.authenticatedAccounts, []) 37 | static let lastAppVersionOpened: AppStorageKVPair = (.lastAppVersionOpened, nil) 38 | static let notificationEmoji: AppStorageKVPair = (.notificationEmoji, false) 39 | static let notificationGrouping: AppStorageKVPair = (.notificationGrouping, .project) 40 | static let projectSummaryDisplayOption: AppStorageKVPair = (.projectSummaryDisplayOption, .productionDeployment) 41 | static let lastAuthenticated: AppStorageKVPair = (.lastAuthenticated, .distantPast) 42 | static let authenticationTimeout: AppStorageKVPair = (.authenticationTimeout, 60 * 10) 43 | static let followLogs: AppStorageKVPair = (.followLogs, false) 44 | 45 | @available(*, deprecated) 46 | static let authenticatedAccountIds: AppStorageKVPair<[VercelAccount.ID]> = (.authenticatedAccountIds, []) 47 | 48 | @AppStorage(Preferences.authenticatedAccounts) 49 | static var accounts 50 | 51 | static let store = UserDefaults(suiteName: "group.me.daneden.Zeitgeist")! 52 | } 53 | 54 | extension AppStorage { 55 | init(_ kv: Preferences.AppStorageKVPair) where Value: RawRepresentable, Value.RawValue == String { 56 | self.init(wrappedValue: kv.value, kv.key.rawValue, store: Preferences.store) 57 | } 58 | 59 | init(_ kv: Preferences.AppStorageKVPair) where Value == Bool { 60 | self.init(wrappedValue: kv.value, kv.key.rawValue, store: Preferences.store) 61 | } 62 | 63 | init(_ kv: Preferences.AppStorageKVPair) where Value == TimeInterval { 64 | self.init(wrappedValue: kv.value, kv.key.rawValue, store: Preferences.store) 65 | } 66 | 67 | init(_ kv: Preferences.AppStorageKVPair) where Value == String? { 68 | self.init(kv.key.rawValue, store: Preferences.store) 69 | } 70 | } 71 | 72 | enum NotificationGrouping: String, Codable, RawRepresentable, CaseIterable { 73 | case account, project, deployment 74 | 75 | var description: LocalizedStringKey { 76 | switch self { 77 | case .project: 78 | return "Project" 79 | case .deployment: 80 | return "Deployment" 81 | case .account: 82 | return "Account" 83 | 84 | } 85 | } 86 | } 87 | 88 | enum ProjectSummaryDisplayOption: String, Codable, CaseIterable { 89 | case productionDeployment, latestDeployment 90 | 91 | var description: LocalizedStringKey { 92 | switch self { 93 | case .latestDeployment: return "Latest deployment" 94 | case .productionDeployment: return "Latest production deployment" 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Shared/Views/Environment Variables/ProjectEnvironmentVariablesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProjectEnvironmentVariablesView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 12/09/2022. 6 | // 7 | 8 | import SwiftUI 9 | import LocalAuthentication 10 | import Suite 11 | 12 | struct ProjectEnvironmentVariablesView: View { 13 | @EnvironmentObject var session: VercelSession 14 | @AppStorage(Preferences.lastAuthenticated) var lastAuthenticated 15 | @AppStorage(Preferences.authenticationTimeout) var authenticationTimeout 16 | @State private var envVars: [VercelEnv] = [] 17 | @State private var editSheetPresented = false 18 | 19 | var isAuthenticated: Bool { 20 | abs(lastAuthenticated.distance(to: .now)) < authenticationTimeout 21 | } 22 | 23 | var projectId: VercelProject.ID 24 | 25 | var body: some View { 26 | ZStack { 27 | Form { 28 | if isAuthenticated { 29 | Section { 30 | ForEach(envVars) { envVar in 31 | EnvironmentVariableRowView(projectId: projectId, envVar: envVar) 32 | .id(envVar.hashValue) 33 | .draggable(envVar) 34 | .contentShape(Rectangle()) 35 | } 36 | } footer: { 37 | Label { 38 | Text("Environment variables with Vercel Secrets values are indicated by a padlock icon. Note that creating and updating Secrets is not currently supported.") 39 | } icon: { 40 | Image(systemName: "lock") 41 | } 42 | } 43 | } 44 | } 45 | .toolbar { 46 | Button { 47 | editSheetPresented = true 48 | } label: { 49 | Label("Add new environment variable", systemImage: "plus") 50 | .backportCircleSymbolVariant() 51 | } 52 | } 53 | .navigationTitle(Text("Environment variables")) 54 | .onAppear { 55 | if !isAuthenticated { 56 | authenticate() 57 | } 58 | } 59 | .dataTask { 60 | await loadEnvironmentVariables() 61 | } 62 | .sheet(isPresented: $editSheetPresented) { 63 | NavigationView { 64 | EnvironmentVariableEditView(projectId: projectId) 65 | } 66 | } 67 | 68 | if !isAuthenticated { 69 | VStack(spacing: 8) { 70 | Image(systemName: "lock") 71 | .font(.largeTitle) 72 | .symbolVariant(.fill) 73 | Text("Authentication required") 74 | .font(.title3) 75 | Button { 76 | authenticate() 77 | } label: { 78 | Text("Authenticate") 79 | }.buttonStyle(.bordered) 80 | } 81 | .foregroundStyle(.secondary) 82 | } else if envVars.isEmpty { 83 | PlaceholderView(forRole: .NoEnvVars) 84 | } 85 | } 86 | #if !os(macOS) 87 | .listStyle(.insetGrouped) 88 | #endif 89 | .animation(.default, value: isAuthenticated) 90 | } 91 | 92 | func loadEnvironmentVariables() async { 93 | do { 94 | var request = VercelAPI.request(for: .projects(projectId, path: "env"), with: session.account.id) 95 | try session.signRequest(&request) 96 | 97 | let (data, _) = try await URLSession.shared.data(for: request) 98 | try withAnimation { 99 | envVars = try JSONDecoder().decode(VercelEnv.APIResponse.self, from: data).envs 100 | } 101 | } catch { 102 | print(error) 103 | } 104 | } 105 | 106 | func authenticate() { 107 | let context = LAContext() 108 | var error: NSError? 109 | 110 | if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { 111 | let reason = "Authentication is required to view decrypted environment variables" 112 | 113 | context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in 114 | if success { 115 | lastAuthenticated = .now 116 | } 117 | 118 | if let authError = authError { 119 | print(authError) 120 | } 121 | } 122 | } else { 123 | // No auth method available 124 | } 125 | } 126 | } 127 | 128 | struct ProjectEnvironmentVariablesView_Previews: PreviewProvider { 129 | static var previews: some View { 130 | ProjectEnvironmentVariablesView(projectId: "nrrrdcore") 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Zeitgeist.xcodeproj/xcshareddata/xcschemes/SelectWidgetAccountIntent.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 59 | 61 | 67 | 68 | 69 | 70 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/RecentDeploymentsWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecentDeploymentstWidget.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | // MARK: - RecentDeploymentsProvider 12 | 13 | struct RecentDeploymentsProvider: IntentTimelineProvider { 14 | func placeholder(in _: Context) -> RecentDeploymentsEntry { 15 | RecentDeploymentsEntry(account: WidgetAccount(identifier: nil, display: "No Account")) 16 | } 17 | 18 | func getSnapshot( 19 | for configuration: SelectAccountIntent, 20 | in context: Context, 21 | completion: @escaping (RecentDeploymentsEntry) -> Void) 22 | { 23 | Task { 24 | guard let intentAccount = configuration.account, 25 | let account = Preferences.accounts.first(where: { $0.id == intentAccount.identifier }) 26 | else { 27 | completion(placeholder(in: context)) 28 | return 29 | } 30 | 31 | do { 32 | let session = VercelSession(account: account) 33 | var queryItems: [URLQueryItem] = [] 34 | 35 | if let projectId = configuration.project?.identifier { 36 | queryItems.append(URLQueryItem(name: "projectId", value: projectId)) 37 | } 38 | 39 | var request = VercelAPI.request(for: .deployments(), with: account.id, queryItems: queryItems) 40 | try session.signRequest(&request) 41 | let (data, _) = try await URLSession.shared.data(for: request) 42 | let deployments = try JSONDecoder().decode(VercelDeployment.APIResponse.self, from: data).deployments 43 | 44 | let relevance: TimelineEntryRelevance? = deployments.prefix(2).first(where: { $0.state == .error }) != nil ? .init(score: 10) : nil 45 | completion(RecentDeploymentsEntry(deployments: deployments, account: intentAccount, project: configuration.project, relevance: relevance)) 46 | } catch { 47 | print(error) 48 | } 49 | } 50 | } 51 | 52 | func getTimeline( 53 | for configuration: SelectAccountIntent, 54 | in context: Context, 55 | completion: @escaping (Timeline) -> Void) 56 | { 57 | Task { 58 | guard let intentAccount = configuration.account, 59 | let account = Preferences.accounts.first(where: { $0.id == intentAccount.identifier }) 60 | else { 61 | completion( 62 | Timeline(entries: [placeholder(in: context)], policy: .atEnd) 63 | ) 64 | return 65 | } 66 | 67 | do { 68 | let session = VercelSession(account: account) 69 | var queryItems: [URLQueryItem] = [] 70 | 71 | if let projectId = configuration.project?.identifier { 72 | queryItems.append(URLQueryItem(name: "projectId", value: projectId)) 73 | } 74 | 75 | var request = VercelAPI.request(for: .deployments(), with: account.id, queryItems: queryItems) 76 | try session.signRequest(&request) 77 | let (data, _) = try await URLSession.shared.data(for: request) 78 | let deployments = try JSONDecoder().decode(VercelDeployment.APIResponse.self, from: data).deployments 79 | 80 | let relevance: TimelineEntryRelevance? = deployments.prefix(2).first(where: { $0.state == .error }) != nil ? .init(score: 10) : nil 81 | completion( 82 | Timeline( 83 | entries: [RecentDeploymentsEntry(deployments: deployments, account: intentAccount, project: configuration.project, relevance: relevance)], 84 | policy: .atEnd 85 | ) 86 | ) 87 | } catch { 88 | print(error) 89 | completion(Timeline(entries: [], policy: .atEnd)) 90 | } 91 | } 92 | } 93 | } 94 | 95 | // MARK: - RecentDeploymentsWidget 96 | 97 | struct RecentDeploymentsWidget: Widget { 98 | public var body: some WidgetConfiguration { 99 | IntentConfiguration( 100 | kind: "RecentDeploymentsWidget", 101 | intent: SelectAccountIntent.self, 102 | provider: RecentDeploymentsProvider() 103 | ) { entry in 104 | RecentDeploymentsWidgetView(config: entry) 105 | } 106 | .configurationDisplayName("Recent Deployments") 107 | .description("View a list of the most recent Vercel deployments for an account or project") 108 | .supportedFamilies([.systemLarge, .systemExtraLarge]) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Shared/Models/GitRepoModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitRepoModel.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 08/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | enum GitSVNProvider: String, Codable { 11 | case bitbucket, github, gitlab 12 | 13 | var name: String { 14 | switch self { 15 | case .bitbucket: 16 | return "Bitbucket" 17 | case .github: 18 | return "GitHub" 19 | case .gitlab: 20 | return "GitLab" 21 | } 22 | } 23 | } 24 | 25 | protocol GitRepo: Decodable { 26 | var type: GitSVNProvider { get } 27 | var org: String { get } 28 | var name: String { get } 29 | var deployHooks: [GitDeployHook] { get } 30 | var updatedAt: Int { get } 31 | var createdAt: Int { get } 32 | var sourceless: Bool? { get } 33 | var productionBranch: String { get } 34 | var gitCredentialId: String { get } 35 | } 36 | 37 | extension GitRepo { 38 | var repoSlug: String { 39 | return "\(org)/\(name)" 40 | } 41 | 42 | var repoUrl: URL? { 43 | switch type { 44 | case .github: 45 | return URL(string: "https://github.com/\(org)/\(name)/") 46 | case .gitlab: 47 | return URL(string: "https://gitlab.com/\(org)/\(name)/") 48 | case .bitbucket: 49 | return URL(string: "https://bitbucket.com/\(org)/\(name)/") 50 | } 51 | } 52 | } 53 | 54 | struct GitDeployHook: Identifiable, Codable { 55 | let createdAt: Int? 56 | let id: String 57 | let name: String 58 | let ref: String 59 | let url: URL 60 | } 61 | 62 | struct GitHubRepo: GitRepo, Codable { 63 | let org: String 64 | let name: String 65 | let type: GitSVNProvider 66 | let deployHooks: [GitDeployHook] 67 | let createdAt: Int 68 | let gitCredentialId: String 69 | let sourceless: Bool? 70 | let updatedAt: Int 71 | let productionBranch: String 72 | 73 | enum CodingKeys: String, CodingKey { 74 | case org, type, deployHooks, gitCredentialId, updatedAt, createdAt, sourceless, productionBranch 75 | 76 | case name = "repo" 77 | } 78 | } 79 | 80 | struct GitLabRepo: GitRepo, Codable { 81 | let org: String 82 | let name: String 83 | let type: GitSVNProvider 84 | let deployHooks: [GitDeployHook] 85 | let createdAt: Int 86 | let gitCredentialId: String 87 | let sourceless: Bool? 88 | let updatedAt: Int 89 | let productionBranch: String 90 | 91 | enum CodingKeys: String, CodingKey { 92 | case type, deployHooks, gitCredentialId, updatedAt, createdAt, sourceless, productionBranch 93 | 94 | case name = "projectName" 95 | case org = "projectNamespace" 96 | } 97 | } 98 | 99 | struct BitBucketRepo: GitRepo, Codable { 100 | let org: String 101 | let name: String 102 | let type: GitSVNProvider 103 | let deployHooks: [GitDeployHook] 104 | let createdAt: Int 105 | let gitCredentialId: String 106 | let sourceless: Bool? 107 | let updatedAt: Int 108 | let productionBranch: String 109 | 110 | enum CodingKeys: String, CodingKey { 111 | case type, deployHooks, gitCredentialId, updatedAt, createdAt, sourceless, productionBranch 112 | case name = "slug" 113 | case org = "owner" 114 | } 115 | } 116 | 117 | struct VercelRepositoryLink: Decodable, GitRepo { 118 | private var wrapped: GitRepo 119 | 120 | var name: String { wrapped.name } 121 | var type: GitSVNProvider { wrapped.type } 122 | var sourceless: Bool? { wrapped.sourceless } 123 | var updatedAt: Int { wrapped.updatedAt } 124 | var createdAt: Int { wrapped.createdAt } 125 | var productionBranch: String { wrapped.productionBranch } 126 | var gitCredentialId: String { wrapped.gitCredentialId } 127 | var deployHooks: [GitDeployHook] { wrapped.deployHooks } 128 | var org: String { wrapped.org } 129 | 130 | init(from decoder: Decoder) throws { 131 | if let githubDecoded = try? GitHubRepo(from: decoder) { 132 | wrapped = githubDecoded 133 | } else if let gitlabDecoded = try? GitLabRepo(from: decoder) { 134 | wrapped = gitlabDecoded 135 | } else if let bitbucketDecoded = try? BitBucketRepo(from: decoder) { 136 | wrapped = bitbucketDecoded 137 | } else { 138 | throw DecodingError.dataCorrupted( 139 | DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to decode repository") 140 | ) 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Shared/Views/Projects/ProjectNotificationsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProjectNotificationsView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 19/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProjectNotificationsView: View { 11 | @Environment(\.dismiss) var dismiss 12 | 13 | var project: VercelProject 14 | 15 | // Assume notifications have been permitted 16 | @State private var notificationsPermitted = true 17 | 18 | @AppStorage(Preferences.deploymentNotificationIds) 19 | private var deploymentNotificationIds 20 | 21 | @AppStorage(Preferences.deploymentErrorNotificationIds) 22 | private var deploymentErrorNotificationIds 23 | 24 | @AppStorage(Preferences.deploymentReadyNotificationIds) 25 | private var deploymentReadyNotificationIds 26 | 27 | @AppStorage(Preferences.deploymentNotificationsProductionOnly) 28 | private var deploymentNotificationsProductionOnly 29 | 30 | var body: some View { 31 | let allowDeploymentNotifications = Binding { 32 | deploymentNotificationIds.contains { $0 == project.id } 33 | } set: { deploymentNotificationIds.toggleElement(project.id, inArray: $0) } 34 | 35 | let allowDeploymentErrorNotifications = Binding { 36 | deploymentErrorNotificationIds.contains { $0 == project.id } 37 | } set: { deploymentErrorNotificationIds.toggleElement(project.id, inArray: $0) } 38 | 39 | let allowDeploymentReadyNotifications = Binding { 40 | deploymentReadyNotificationIds.contains { $0 == project.id } 41 | } set: { deploymentReadyNotificationIds.toggleElement(project.id, inArray: $0) } 42 | 43 | let productionNotificationsOnly = Binding { 44 | deploymentNotificationsProductionOnly.contains { $0 == project.id } 45 | } set: { deploymentNotificationsProductionOnly.toggleElement(project.id, inArray: $0) } 46 | 47 | return Form { 48 | if !notificationsPermitted { 49 | Section("Notification permissions required") { 50 | Text("Go to the Settings app to enable notifications for Zeitgeist") 51 | #if os(iOS) 52 | if let url = URL(string: UIApplication.openSettingsURLString), 53 | UIApplication.shared.canOpenURL(url) { 54 | Link(destination: url) { 55 | Text("Open Settings") 56 | } 57 | } 58 | #endif 59 | } 60 | } 61 | 62 | Section { 63 | Toggle(isOn: productionNotificationsOnly) { 64 | Label("Production only", systemImage: "theatermasks.fill") 65 | } 66 | } footer: { 67 | Text("When enabled, Zeitgeist will only send the notification types selected below for deployments targeting a production environment.") 68 | } 69 | 70 | Section { 71 | Toggle(isOn: allowDeploymentNotifications) { 72 | DeploymentStateIndicator(state: .building) 73 | } 74 | 75 | Toggle(isOn: allowDeploymentErrorNotifications) { 76 | DeploymentStateIndicator(state: .error) 77 | } 78 | 79 | Toggle(isOn: allowDeploymentReadyNotifications) { 80 | DeploymentStateIndicator(state: .ready) 81 | } 82 | } 83 | } 84 | .toolbar { 85 | Button { 86 | dismiss() 87 | } label: { 88 | Label("Dismiss", systemImage: "xmark") 89 | } 90 | } 91 | .navigationTitle(Text("Notifications for \(project.name)")) 92 | #if os(iOS) 93 | .navigationBarTitleDisplayMode(.inline) 94 | #endif 95 | .onAppear { 96 | if notificationsChanged { 97 | requestAndUpdateNotificationPermittedStatus() 98 | } 99 | } 100 | .onChange(of: overallNotificationSettings) { _ in 101 | requestAndUpdateNotificationPermittedStatus() 102 | } 103 | } 104 | 105 | func requestAndUpdateNotificationPermittedStatus() { 106 | Task { 107 | if let auth = try? await NotificationManager.requestAuthorization() { 108 | notificationsPermitted = auth 109 | } else { 110 | notificationsPermitted = false 111 | } 112 | } 113 | } 114 | } 115 | 116 | extension ProjectNotificationsView { 117 | private var notificationsChanged: Bool { 118 | !overallNotificationSettings.isEmpty 119 | } 120 | 121 | private var overallNotificationSettings: [String] { 122 | (deploymentNotificationIds + deploymentReadyNotificationIds + deploymentErrorNotificationIds + deploymentNotificationsProductionOnly) 123 | } 124 | } 125 | 126 | // struct ProjectNotificationsView_Previews: PreviewProvider { 127 | // static var previews: some View { 128 | // ProjectNotificationsView() 129 | // } 130 | // } 131 | -------------------------------------------------------------------------------- /AppIcon.icon/icon.json: -------------------------------------------------------------------------------- 1 | { 2 | "color-space-for-untagged-svg-colors" : "display-p3", 3 | "fill-specializations" : [ 4 | { 5 | "value" : { 6 | "automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000" 7 | } 8 | }, 9 | { 10 | "appearance" : "dark", 11 | "value" : "automatic" 12 | } 13 | ], 14 | "groups" : [ 15 | { 16 | "blend-mode-specializations" : [ 17 | { 18 | "appearance" : "dark", 19 | "value" : "normal" 20 | } 21 | ], 22 | "blur-material-specializations" : [ 23 | { 24 | "value" : 0.5 25 | }, 26 | { 27 | "appearance" : "dark", 28 | "value" : null 29 | } 30 | ], 31 | "layers" : [ 32 | { 33 | "glass-specializations" : [ 34 | { 35 | "appearance" : "dark", 36 | "value" : true 37 | } 38 | ], 39 | "hidden" : false, 40 | "image-name" : "Mask Group.png", 41 | "name" : "Mask Group", 42 | "opacity-specializations" : [ 43 | { 44 | "value" : 0 45 | }, 46 | { 47 | "appearance" : "dark", 48 | "value" : 1 49 | } 50 | ], 51 | "position" : { 52 | "scale" : 0.72, 53 | "translation-in-points" : [ 54 | 0, 55 | 0 56 | ] 57 | } 58 | }, 59 | { 60 | "hidden" : false, 61 | "image-name" : "Triangle.svg", 62 | "name" : "Triangle", 63 | "opacity-specializations" : [ 64 | { 65 | "appearance" : "dark", 66 | "value" : 0 67 | } 68 | ], 69 | "position" : { 70 | "scale" : 2.15, 71 | "translation-in-points" : [ 72 | 0, 73 | 33 74 | ] 75 | } 76 | } 77 | ], 78 | "name" : "Group", 79 | "position" : { 80 | "scale" : 1, 81 | "translation-in-points" : [ 82 | 0, 83 | -16.46875 84 | ] 85 | }, 86 | "shadow-specializations" : [ 87 | { 88 | "appearance" : "dark", 89 | "value" : { 90 | "kind" : "neutral", 91 | "opacity" : 0.5 92 | } 93 | } 94 | ], 95 | "specular-specializations" : [ 96 | { 97 | "appearance" : "dark", 98 | "value" : true 99 | } 100 | ], 101 | "translucency-specializations" : [ 102 | { 103 | "value" : { 104 | "enabled" : true, 105 | "value" : 0.5 106 | } 107 | }, 108 | { 109 | "appearance" : "dark", 110 | "value" : { 111 | "enabled" : false, 112 | "value" : 0.5 113 | } 114 | } 115 | ] 116 | }, 117 | { 118 | "layers" : [ 119 | { 120 | "fill-specializations" : [ 121 | { 122 | "appearance" : "dark", 123 | "value" : "none" 124 | }, 125 | { 126 | "appearance" : "tinted", 127 | "value" : "none" 128 | } 129 | ], 130 | "glass" : false, 131 | "hidden" : false, 132 | "image-name" : "Background.png", 133 | "name" : "Background", 134 | "opacity-specializations" : [ 135 | { 136 | "appearance" : "dark", 137 | "value" : 0 138 | }, 139 | { 140 | "appearance" : "tinted", 141 | "value" : 0 142 | } 143 | ], 144 | "position" : { 145 | "scale" : 1.1, 146 | "translation-in-points" : [ 147 | 0, 148 | 0 149 | ] 150 | } 151 | } 152 | ], 153 | "shadow" : { 154 | "kind" : "neutral", 155 | "opacity" : 0.5 156 | }, 157 | "specular" : true, 158 | "translucency" : { 159 | "enabled" : true, 160 | "value" : 0.5 161 | } 162 | } 163 | ], 164 | "supported-platforms" : { 165 | "circles" : [ 166 | "watchOS" 167 | ], 168 | "squares" : "shared" 169 | } 170 | } -------------------------------------------------------------------------------- /Shared/Views/Environment Variables/EnvironmentVariableEditView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentVariableEditView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 12/09/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct EnvironmentVariableEditView: View { 11 | @EnvironmentObject var session: VercelSession 12 | @Environment(\.dismiss) private var dismiss 13 | 14 | var projectId: VercelProject.ID 15 | var id: VercelEnv.ID? 16 | @State var key = "" 17 | @State var value = "" 18 | 19 | @State var targetProduction = true 20 | @State var targetPreview = true 21 | @State var targetDevelopment = true 22 | @State private var saving = false 23 | 24 | private var envVarIsValid: Bool { 25 | (targetPreview || targetProduction || targetDevelopment) && 26 | key.range(of: #"^[a-zA-Z][_\w]*$"#, options: .regularExpression) != nil 27 | } 28 | 29 | var navBarTitle: Text { 30 | switch id { 31 | case .none: return Text("Add environment variable") 32 | case .some(_): return Text("Edit environment variable") 33 | } 34 | } 35 | 36 | var body: some View { 37 | Form { 38 | Section { 39 | TextField("Name", text: $key) 40 | .font(.body.monospaced()) 41 | .autocorrectionDisabled(true) 42 | } footer: { 43 | Text("Environment variable names must begin with a letter and can only contain letters, numbers, and underscores") 44 | } 45 | 46 | Section("Value") { 47 | TextEditor(text: $value) 48 | .font(.body.monospaced()) 49 | .frame(minHeight: 80) 50 | .autocorrectionDisabled(true) 51 | } 52 | 53 | Section { 54 | Toggle(isOn: $targetProduction) { 55 | Text("Production") 56 | } 57 | 58 | Toggle(isOn: $targetPreview) { 59 | Text("Preview") 60 | } 61 | 62 | Toggle(isOn: $targetDevelopment) { 63 | Text("Development") 64 | } 65 | } header: { 66 | Text("Environment") 67 | } footer: { 68 | Text("At least one target must be selected. For more advanced settings, such as custom Git branch configuration for Preview targets, configure your environment variable on Vercel’s website.") 69 | } 70 | 71 | Section { 72 | Button { 73 | Task { 74 | await saveEnvVar() 75 | } 76 | } label: { 77 | HStack { 78 | Text("Submit", comment: "Button label to save a new environment variable") 79 | 80 | if saving { 81 | Spacer() 82 | ProgressView() 83 | } 84 | } 85 | } 86 | .disabled(!envVarIsValid) 87 | .disabled(saving) 88 | } footer: { 89 | Text("A new deployment is required for your changes to take effect.") 90 | } 91 | } 92 | .navigationTitle(navBarTitle) 93 | .toolbar { 94 | Button { 95 | dismiss() 96 | } label: { 97 | Text("Cancel") 98 | } 99 | } 100 | #if os(iOS) 101 | .navigationBarTitleDisplayMode(.inline) 102 | #endif 103 | } 104 | 105 | func saveEnvVar() async { 106 | saving = true 107 | 108 | do { 109 | var path = "env" 110 | var method: VercelAPI.RequestMethod = .POST 111 | if let id { 112 | path += "/\(id)" 113 | method = .PATCH 114 | } 115 | var request: URLRequest = VercelAPI.request(for: .projects(projectId, path: path), with: session.account.id, method: method) 116 | 117 | var targets = [String]() 118 | 119 | if targetProduction { targets.append("production") } 120 | if targetPreview { targets.append("preview") } 121 | if targetDevelopment { targets.append("development") } 122 | 123 | let body: [String: Any] = [ 124 | "key": key, 125 | "value": value, 126 | "target": targets, 127 | "type": "encrypted" 128 | ] 129 | 130 | let encoded = try JSONSerialization.data(withJSONObject: body) 131 | request.httpBody = encoded 132 | 133 | try session.signRequest(&request) 134 | let (data, _) = try await URLSession.shared.data(for: request) 135 | 136 | let response = try JSONDecoder().decode(VercelEnv.self, from: data) 137 | print("Successfully created/updated env var with key \(response.key)") 138 | 139 | dismiss() 140 | DataTaskModifier.postNotification() 141 | } catch { 142 | print(error) 143 | } 144 | 145 | saving = false 146 | } 147 | } 148 | 149 | struct EnvironmentVariableEditView_Previews: PreviewProvider { 150 | static var previews: some View { 151 | EnvironmentVariableEditView(projectId: "nrrrdcore") 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Shared/Views/Projects/ProjectsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProjectsListView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 08/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Suite 10 | 11 | struct LoadingListCell: View { 12 | var title: LocalizedStringKey = "Loading" 13 | var body: some View { 14 | HStack(spacing: 8) { 15 | ProgressView().controlSize(.small) 16 | Text(title) 17 | } 18 | .foregroundStyle(.secondary) 19 | } 20 | } 21 | 22 | struct ProjectsListView: View { 23 | @AppStorage(Preferences.projectSummaryDisplayOption) var projectSummaryDisplayOption 24 | @EnvironmentObject var session: VercelSession 25 | 26 | @State private var projects: [VercelProject] = [] 27 | @State private var pagination: Pagination? 28 | @State private var searchText = "" 29 | @State private var projectsError: SessionError? 30 | 31 | var filteredProjects: [VercelProject] { 32 | if searchText.isEmpty { 33 | return projects 34 | } else { 35 | return projects.filter { project in 36 | project.name.localizedCaseInsensitiveContains(searchText) || project.link?.repoSlug.localizedCaseInsensitiveContains(searchText) == true 37 | } 38 | } 39 | } 40 | 41 | var body: some View { 42 | ZStack { 43 | List { 44 | ForEach(filteredProjects) { project in 45 | NavigationLink { 46 | ProjectDetailView(projectId: project.id, project: project) 47 | .id(project.id) 48 | .environmentObject(session) 49 | } label: { 50 | ProjectsListRowView(project: project) 51 | .id(project.id) 52 | } 53 | } 54 | 55 | if let pageId = pagination?.next { 56 | LoadingListCell(title: "Loading projects") 57 | .task { 58 | do { 59 | try await loadProjects(pageId: pageId) 60 | } catch { 61 | print(error) 62 | } 63 | } 64 | } 65 | } 66 | .searchable(text: $searchText) 67 | .toolbar { 68 | Menu { 69 | Picker(selection: $projectSummaryDisplayOption) { 70 | ForEach(ProjectSummaryDisplayOption.allCases, id: \.self) { option in 71 | Text(option.description) 72 | .tag(option) 73 | } 74 | } label: { 75 | Label("Show deployment cause for...", systemImage: "rectangle.and.text.magnifyingglass") 76 | } 77 | } label: { 78 | Label("View options", systemImage: "eye") 79 | .backportCircleSymbolVariant() 80 | } 81 | } 82 | .dataTask { 83 | do { 84 | try await loadProjects() 85 | } catch { 86 | print(error) 87 | if let error = error as? SessionError { 88 | self.projectsError = error 89 | } 90 | } 91 | } 92 | 93 | if projects.isEmpty && projectsError == nil { 94 | PlaceholderView(forRole: .NoProjects) 95 | } 96 | 97 | if projectsError != nil { 98 | PlaceholderView(forRole: .AuthError) 99 | } 100 | } 101 | .navigationTitle(Text("Projects")) 102 | .permissionRevocationDialog(session: session) 103 | } 104 | 105 | func loadProjects(pageId: Int? = nil) async throws { 106 | if session.requestsDenied == true { return } 107 | 108 | var params: [URLQueryItem] = [] 109 | 110 | if let pageId = pageId { 111 | params.append(URLQueryItem(name: "from", value: String(pageId - 1))) 112 | } 113 | 114 | var request = VercelAPI.request(for: .projects(), with: session.account.id, queryItems: params) 115 | try session.signRequest(&request) 116 | 117 | if pageId == nil, 118 | let cachedResponse = URLCache.shared.cachedResponse(for: request), 119 | let decodedFromCache = try? JSONDecoder().decode(VercelProject.APIResponse.self, from: cachedResponse.data) 120 | { 121 | projects = decodedFromCache.projects 122 | } 123 | 124 | let (data, response) = try await URLSession.shared.data(for: request) 125 | session.validateResponse(response) 126 | let decoded = try JSONDecoder().decode(VercelProject.APIResponse.self, from: data) 127 | withAnimation { 128 | if pageId != nil { 129 | self.projects.append(contentsOf: decoded.projects) 130 | } else { 131 | self.projects = decoded.projects 132 | } 133 | self.pagination = decoded.pagination 134 | } 135 | } 136 | } 137 | 138 | struct ProjectListPlaceholderView: View { 139 | var body: some View { 140 | NavigationView { 141 | List { 142 | ForEach(0..<10, id: \.self) { _ in 143 | ProjectsListRowView(project: .exampleData) 144 | } 145 | } 146 | .navigationTitle(Text("Projects")) 147 | }.redacted(reason: .placeholder) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/LatestDeploymentWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LatestDeploymentWidget.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | // MARK: - LatestDeploymentProvider 12 | 13 | struct LatestDeploymentProvider: IntentTimelineProvider { 14 | func placeholder(in _: Context) -> LatestDeploymentEntry { 15 | LatestDeploymentEntry(account: WidgetAccount(identifier: nil, display: "No Account")) 16 | } 17 | 18 | func getSnapshot( 19 | for configuration: SelectAccountIntent, 20 | in context: Context, 21 | completion: @escaping (LatestDeploymentEntry) -> Void) 22 | { 23 | Task { 24 | guard let intentAccount = configuration.account, 25 | let account = Preferences.accounts.first(where: { $0.id == intentAccount.identifier }) 26 | else { 27 | completion(placeholder(in: context)) 28 | return 29 | } 30 | 31 | do { 32 | let session = VercelSession(account: account) 33 | var queryItems: [URLQueryItem] = [] 34 | 35 | if let projectId = configuration.project?.identifier { 36 | queryItems.append(URLQueryItem(name: "projectId", value: projectId)) 37 | } 38 | 39 | var request = VercelAPI.request(for: .deployments(), with: account.id, queryItems: queryItems) 40 | try session.signRequest(&request) 41 | let (data, _) = try await URLSession.shared.data(for: request) 42 | let deployments = try JSONDecoder().decode(VercelDeployment.APIResponse.self, from: data).deployments 43 | 44 | let relevance: TimelineEntryRelevance? = deployments.prefix(2).first(where: { $0.state == .error }) != nil ? .init(score: 10) : nil 45 | if let deployment = deployments.first { 46 | completion( 47 | LatestDeploymentEntry( 48 | deployment: deployment, 49 | account: intentAccount, 50 | project: configuration.project, 51 | relevance: relevance 52 | ) 53 | ) 54 | } 55 | } catch { 56 | print(error) 57 | } 58 | } 59 | } 60 | 61 | func getTimeline( 62 | for configuration: SelectAccountIntent, 63 | in context: Context, 64 | completion: @escaping (Timeline) -> Void) 65 | { 66 | Task { 67 | guard let intentAccount = configuration.account, 68 | let account = Preferences.accounts.first(where: { $0.id == intentAccount.identifier }) 69 | else { 70 | completion( 71 | Timeline(entries: [placeholder(in: context)], policy: .atEnd) 72 | ) 73 | return 74 | } 75 | 76 | do { 77 | let session = VercelSession(account: account) 78 | var queryItems: [URLQueryItem] = [] 79 | 80 | if let projectId = configuration.project?.identifier { 81 | queryItems.append(URLQueryItem(name: "projectId", value: projectId)) 82 | } 83 | 84 | var request = VercelAPI.request(for: .deployments(), with: account.id, queryItems: queryItems) 85 | try session.signRequest(&request) 86 | let (data, _) = try await URLSession.shared.data(for: request) 87 | let deployments = try JSONDecoder().decode(VercelDeployment.APIResponse.self, from: data).deployments 88 | 89 | let relevance: TimelineEntryRelevance? = deployments.prefix(2).first(where: { $0.state == .error }) != nil ? .init(score: 10) : nil 90 | if let deployment = deployments.first { 91 | completion( 92 | Timeline(entries: [ 93 | LatestDeploymentEntry( 94 | deployment: deployment, 95 | account: intentAccount, 96 | project: configuration.project, 97 | relevance: relevance 98 | ), 99 | ], policy: .atEnd) 100 | ) 101 | } else { 102 | completion(Timeline(entries: [], policy: .atEnd)) 103 | } 104 | } catch { 105 | print(error) 106 | } 107 | } 108 | } 109 | } 110 | 111 | // MARK: - LatestDeploymentWidget 112 | 113 | struct LatestDeploymentWidget: Widget { 114 | 115 | // MARK: Public 116 | 117 | public var body: some WidgetConfiguration { 118 | IntentConfiguration( 119 | kind: "LatestDeploymentWidget", 120 | intent: SelectAccountIntent.self, 121 | provider: LatestDeploymentProvider() 122 | ) { entry in 123 | LatestDeploymentWidgetView(config: entry) 124 | } 125 | .configurationDisplayName("Latest Deployment") 126 | .description("View the most recent Vercel deployment for an account or project") 127 | .supportedFamilies(supportedFamilies) 128 | } 129 | 130 | // MARK: Private 131 | 132 | private var supportedFamilies: [WidgetFamily] { 133 | if #available(iOSApplicationExtension 16.0, *) { 134 | [.systemSmall, .systemMedium, .accessoryRectangular, .accessoryCircular] 135 | } else { 136 | [.systemSmall, .systemMedium] 137 | } 138 | } 139 | 140 | } 141 | -------------------------------------------------------------------------------- /Shared/Views/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 05/06/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | @Environment(\.dismiss) var dismiss 12 | @AppStorage(Preferences.deploymentNotificationIds) private var deploymentNotificationIds 13 | @AppStorage(Preferences.deploymentErrorNotificationIds) private var deploymentErrorNotificationIds 14 | @AppStorage(Preferences.deploymentReadyNotificationIds) private var deploymentReadyNotificationIds 15 | @AppStorage(Preferences.deploymentNotificationsProductionOnly) private var deploymentProductionNotificationIds 16 | 17 | @AppStorage(Preferences.notificationEmoji) var notificationEmoji 18 | @AppStorage(Preferences.notificationGrouping) var notificationGrouping 19 | 20 | @AppStorage(Preferences.authenticationTimeout) var authenticationTimeout 21 | 22 | var githubIssuesURL: URL { 23 | 24 | var body = """ 25 | > Please give a detailed description of the issue you’re experiencing or the feedback you’d like to provide. 26 | > Feel free to attach any relevant screenshots or logs, and please keep the app version and device info in the issue! 27 | 28 | App Version: \(ZeitgeistApp.appVersion) 29 | """ 30 | 31 | #if os(iOS) 32 | body += """ 33 | Device: \(UIDevice.modelName) 34 | OS: \(UIDevice.current.systemName) \(UIDevice.current.systemVersion) 35 | """ 36 | #endif 37 | let encodedBody = body.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "" 38 | 39 | return URL(string: "https://github.com/daneden/zeitgeist/issues/new?body=\(encodedBody)")! 40 | } 41 | 42 | var body: some View { 43 | Form { 44 | Section { 45 | Picker(selection: $notificationGrouping) { 46 | ForEach(NotificationGrouping.allCases, id: \.self) { grouping in 47 | Text(grouping.description) 48 | } 49 | } label: { 50 | Text("Group notifications by") 51 | } 52 | 53 | Toggle(isOn: $notificationEmoji) { 54 | Text("Show Emoji in notification titles") 55 | } 56 | } header: { 57 | Text("Notifications") 58 | } footer: { 59 | Text("Optionally display emoji to quickly denote different build statuses: ⏱ Build Started, ✅ Deployed, and 🛑 Build Failed") 60 | } 61 | 62 | Section { 63 | Picker(selection: $authenticationTimeout) { 64 | ForEach(timeoutPresets, id: \.self) { preset in 65 | Text(Duration.seconds(preset).formatted(.units())) 66 | } 67 | 68 | Text("Never").tag(TimeInterval.infinity) 69 | } label: { 70 | Text("Auto-lock after") 71 | } 72 | } header: { 73 | Text("Authentication") 74 | } footer: { 75 | Text("Authentication is used to protect sensitive information such as environment variables") 76 | } 77 | 78 | Section { 79 | Link(destination: githubIssuesURL) { 80 | Label("Submit feedback", systemImage: "ladybug") 81 | } 82 | 83 | Link(destination: .ReviewURL) { 84 | Label("Review on the App Store", systemImage: "star.fill") 85 | } 86 | } 87 | 88 | Section { 89 | Link(destination: URL(string: "https://zeitgeist.daneden.me/privacy")!) { 90 | Text("Privacy Policy") 91 | } 92 | 93 | Link(destination: URL(string: "https://zeitgeist.daneden.me/terms")!) { 94 | Text("Terms of Use") 95 | } 96 | } 97 | 98 | Section("Danger Zone") { 99 | Button { 100 | resetNotifications() 101 | } label: { 102 | Label("Reset notification settings", systemImage: "bell.slash") 103 | }.disabled(notificationsResettable) 104 | 105 | Button(role: .destructive) { 106 | Preferences.accounts.forEach { account in 107 | VercelSession.deleteAccount(id: account.id) 108 | } 109 | dismiss() 110 | } label: { 111 | Label("Sign out of all accounts", systemImage: "person.badge.minus") 112 | } 113 | }.symbolRenderingMode(.multicolor) 114 | } 115 | .navigationTitle(Text("Settings")) 116 | #if os(iOS) 117 | .toolbar { 118 | BackportCloseButton { 119 | dismiss() 120 | } 121 | } 122 | #endif 123 | 124 | } 125 | } 126 | 127 | extension SettingsView { 128 | func resetNotifications() { 129 | DispatchQueue.main.async { 130 | notificationEmoji = false 131 | deploymentNotificationIds.removeAll() 132 | deploymentReadyNotificationIds.removeAll() 133 | deploymentErrorNotificationIds.removeAll() 134 | deploymentProductionNotificationIds.removeAll() 135 | } 136 | } 137 | 138 | var notificationsResettable: Bool { 139 | (deploymentNotificationIds + deploymentErrorNotificationIds + deploymentReadyNotificationIds + deploymentProductionNotificationIds).isEmpty 140 | } 141 | } 142 | 143 | fileprivate let timeoutPresets: Array = [ 144 | 60 * 1, 145 | 60 * 5, 146 | 60 * 10, 147 | 60 * 15, 148 | 60 * 30, 149 | 60 * 60 150 | ] 151 | 152 | struct SettingsView_Previews: PreviewProvider { 153 | static var previews: some View { 154 | SettingsView() 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Views/LatestDeploymentWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LatestDeploymentWidgetView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // Updated by Brad Bergeron on 22/11/2023. 7 | // 8 | 9 | import SwiftUI 10 | import WidgetKit 11 | 12 | // MARK: - LatestDeploymentWidgetView 13 | 14 | struct LatestDeploymentWidgetView: View { 15 | 16 | // MARK: Internal 17 | 18 | let config: LatestDeploymentEntry 19 | 20 | var body: some View { 21 | Group { 22 | switch widgetFamily { 23 | case .systemSmall, .systemMedium: 24 | systemView 25 | case .systemLarge, .systemExtraLarge: 26 | /// These sizes are unsupported by the widget. See ``LatestDeploymentWidget`` for configuration. 27 | Color.clear 28 | case .accessoryCircular: 29 | circularAccessoryView 30 | case .accessoryRectangular, .accessoryInline: 31 | accessoryView 32 | @unknown default: 33 | Color.clear 34 | } 35 | } 36 | } 37 | 38 | // MARK: Private 39 | 40 | @Environment(\.widgetFamily) private var widgetFamily 41 | 42 | private var hasProject: Bool { 43 | config.project?.identifier != nil 44 | } 45 | 46 | private var systemView: some View { 47 | Link(destination: URL(string: "zeitgeist://open/\(config.account.identifier ?? "0")/\(config.deployment?.id ?? "0")")!) { 48 | VStack(alignment: .leading, spacing: 4) { 49 | if let deployment = config.deployment { 50 | HStack { 51 | DeploymentStateIndicator(state: deployment.state) 52 | Spacer() 53 | if deployment.target == .production { 54 | Image(systemName: "theatermasks") 55 | .foregroundStyle(.tint) 56 | .symbolVariant(.fill) 57 | .imageScale(.small) 58 | .widgetAccentable() 59 | } 60 | } 61 | .font(.caption.bold()) 62 | .padding(.bottom, 2) 63 | 64 | Text(deployment.deploymentCause.description) 65 | .font(.subheadline) 66 | .fontWeight(.bold) 67 | .lineLimit(3) 68 | 69 | Text(deployment.created, style: .relative) 70 | .foregroundStyle(.secondary) 71 | 72 | if !hasProject { 73 | Text(deployment.project) 74 | .lineLimit(1) 75 | .foregroundStyle(.secondary) 76 | } 77 | } else { 78 | PlaceholderView(forRole: .NoDeployments, alignment: .leading) 79 | .font(.footnote) 80 | } 81 | 82 | Spacer() 83 | 84 | Group { 85 | WidgetLabel(label: config.account.displayString, iconName: config.account.identifier?.isTeam == true ? "person.2" : "person") 86 | .symbolVariant(config.account.identifier == nil ? .none : .fill) 87 | 88 | if let project = config.project, 89 | project.identifier != nil { 90 | WidgetLabel(label: project.displayString, iconName: "folder") 91 | } 92 | } 93 | .foregroundStyle(.secondary) 94 | .imageScale(.small) 95 | .lineLimit(1) 96 | } 97 | .multilineTextAlignment(.leading) 98 | .frame(maxWidth: .infinity, alignment: .leading) 99 | } 100 | .font(.footnote) 101 | .foregroundStyle(.primary) 102 | .symbolRenderingMode(.hierarchical) 103 | .tint(.indigo) 104 | } 105 | 106 | private var circularAccessoryView: some View { 107 | Image(systemName: config.deployment?.state.imageName ?? "arrowtriangle.up.circle") 108 | .imageScale(.large) 109 | .font(.largeTitle) 110 | } 111 | 112 | private var accessoryView: some View { 113 | VStack(alignment: .leading) { 114 | if let deployment = config.deployment { 115 | Label { 116 | Text(deployment.project) 117 | .font(.headline) 118 | } icon: { 119 | DeploymentStateIndicator(state: deployment.state, style: .compact) 120 | .symbolRenderingMode(.monochrome) 121 | } 122 | 123 | Text(deployment.deploymentCause.description) 124 | .lineLimit(2) 125 | Text(deployment.created, style: .relative) 126 | .foregroundStyle(.secondary) 127 | } else { 128 | Group { 129 | HStack { 130 | DeploymentStateIndicator(state: .queued, style: .compact) 131 | Text("Loading...") 132 | } 133 | Text("Waiting for data") 134 | .foregroundStyle(.secondary) 135 | Text(.now, style: .relative) 136 | .foregroundStyle(.tertiary) 137 | } 138 | .redacted(reason: .placeholder) 139 | } 140 | } 141 | .allowsTightening(true) 142 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) 143 | } 144 | 145 | } 146 | 147 | #if DEBUG 148 | 149 | struct LatestDeploymentWidgetView_Previews: PreviewProvider { 150 | 151 | // MARK: Internal 152 | 153 | static var previews: some View { 154 | LatestDeploymentWidgetView(config: .mockNoAccount) 155 | .previewContext(WidgetPreviewContext(family: widgetFamily)) 156 | .previewDisplayName("No Account") 157 | 158 | LatestDeploymentWidgetView(config: .mockExample) 159 | .previewContext(WidgetPreviewContext(family: widgetFamily)) 160 | .previewDisplayName("Example") 161 | } 162 | 163 | // MARK: Private 164 | 165 | @Environment(\.widgetFamily) private static var widgetFamily 166 | 167 | } 168 | 169 | #endif 170 | -------------------------------------------------------------------------------- /Shared/Views/Environment Variables/EnvironmentVariableRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentVariableRowView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 13/09/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct EnvironmentVariableRowView: View { 11 | @EnvironmentObject var session: VercelSession 12 | var projectId: VercelProject.ID 13 | @State var envVar: VercelEnv 14 | @State private var loading = false 15 | @State private var confirmDeletion = false 16 | @State private var editing = false 17 | 18 | var needsDecrypting: Bool { 19 | envVar.decrypted == false && envVar.type == .encrypted 20 | } 21 | 22 | var body: some View { 23 | VStack(alignment: .leading, spacing: 4) { 24 | HStack { 25 | Text(envVar.key) 26 | .lineLimit(2) 27 | .font(.footnote.monospaced()) 28 | 29 | Spacer() 30 | 31 | Text(envVar.updated, style: .relative) 32 | .font(.caption) 33 | .foregroundStyle(.secondary) 34 | } 35 | 36 | HStack(spacing: 4) { 37 | if envVar.type == .secret { 38 | Image(systemName: "lock") 39 | } 40 | 41 | Text(verbatim: needsDecrypting ? Array(repeating: "•", count: 15).joined() : envVar.value) 42 | .privacySensitive(!needsDecrypting) 43 | .contentTransition(.numericText()) 44 | .animation(.default, value: envVar.value) 45 | 46 | if loading { 47 | ProgressView() 48 | .controlSize(.mini) 49 | } 50 | } 51 | .font(.footnote.monospaced()) 52 | .foregroundStyle(.secondary) 53 | 54 | Text(LocalizedStringKey(envVar.target.map { $0.capitalized }.formatted(.list(type: .and))), comment: "A list of target environments for an environment variable") 55 | .font(.caption) 56 | .foregroundStyle(.tertiary) 57 | .textSelection(.disabled) 58 | } 59 | .sheet(isPresented: $editing) { 60 | NavigationView { 61 | EnvironmentVariableEditView(projectId: projectId, 62 | id: envVar.id, 63 | key: envVar.key, 64 | value: envVar.value, 65 | targetProduction: envVar.targetsProduction, 66 | targetPreview: envVar.targetsPreview, 67 | targetDevelopment: envVar.targetsDevelopment) 68 | } 69 | } 70 | .onTapGesture { 71 | if needsDecrypting { 72 | Task { 73 | loading = true 74 | if let newValue = await decryptedValue() { 75 | envVar = newValue 76 | } 77 | loading = false 78 | } 79 | } 80 | } 81 | .textSelection(.enabled) 82 | .contextMenu { 83 | Button { 84 | Task { 85 | if needsDecrypting == false { 86 | Pasteboard.setString("\(envVar.key)=\(envVar.value)") 87 | } else { 88 | loading = true 89 | 90 | if let newValue = await decryptedValue() { 91 | Pasteboard.setString("\(newValue.key)=\(newValue.value)") 92 | } 93 | loading = false 94 | } 95 | } 96 | } label: { 97 | Label { 98 | HStack { 99 | Text("Copy") 100 | 101 | if loading { 102 | Spacer() 103 | ProgressView() 104 | } 105 | } 106 | } icon: { 107 | Image(systemName: "doc.on.doc") 108 | } 109 | } 110 | 111 | Button { 112 | if needsDecrypting == false { 113 | editing = true 114 | } else { 115 | Task { 116 | if let newValue = await decryptedValue() { 117 | envVar = newValue 118 | editing = true 119 | } 120 | } 121 | } 122 | } label: { 123 | Label("Edit", systemImage: "pencil") 124 | } 125 | 126 | Button(role: .destructive) { 127 | confirmDeletion = true 128 | } label: { 129 | Label("Delete", systemImage: "trash") 130 | } 131 | } 132 | .confirmationDialog("Delete environment variable", isPresented: $confirmDeletion) { 133 | Button(role: .cancel) { 134 | confirmDeletion = false 135 | } label: { 136 | Text("Cancel") 137 | } 138 | 139 | Button(role: .destructive) { 140 | Task { 141 | await delete() 142 | DataTaskModifier.postNotification() 143 | } 144 | } label: { 145 | Text("Delete") 146 | } 147 | } message: { 148 | Text("Are you sure you want to permanently delete this environment variable?") 149 | } 150 | } 151 | 152 | func decryptedValue() async -> VercelEnv? { 153 | do { 154 | var request = VercelAPI.request(for: .projects(projectId, path: "env/\(envVar.id)"), with: session.account.id) 155 | try session.signRequest(&request) 156 | 157 | let (data, _) = try await URLSession.shared.data(for: request) 158 | return try JSONDecoder().decode(VercelEnv.self, from: data) 159 | } catch { 160 | print(error) 161 | } 162 | 163 | return nil 164 | } 165 | 166 | func delete() async { 167 | do { 168 | var request = VercelAPI.request(for: .projects(projectId, path: "env/\(envVar.id)"), with: session.account.id, method: .DELETE) 169 | try session.signRequest(&request) 170 | 171 | _ = try await URLSession.shared.data(for: request) 172 | } catch { 173 | print(error) 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Shared/Helpers/PlaceholderData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaceholderData.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 07/08/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | extension VercelProject { 11 | static var exampleData: VercelProject { 12 | let jsonData = """ 13 | { 14 | "accountId": "ErNXfZNwbyDvjvkDpfbyqxqvA33W", 15 | "autoExposeSystemEnvs": true, 16 | "buildCommand": null, 17 | "createdAt": 1659643229407, 18 | "devCommand": null, 19 | "directoryListing": false, 20 | "env": [], 21 | "framework": "nextjs", 22 | "gitForkProtection": true, 23 | "id": "prj_TwmwXFAcQ7eGeQdqRjBasDzBUMne", 24 | "installCommand": null, 25 | "name": "example-project", 26 | "nodeVersion": "16.x", 27 | "outputDirectory": null, 28 | "publicSource": null, 29 | "rootDirectory": null, 30 | "serverlessFunctionRegion": "iad1", 31 | "sourceFilesOutsideRootDirectory": true, 32 | "updatedAt": 1659860840289, 33 | "live": false, 34 | "link": { 35 | "type": "github", 36 | "repo": "example-repo", 37 | "repoId": 521398877, 38 | "org": "example", 39 | "gitCredentialId": "example_id", 40 | "productionBranch": "main", 41 | "sourceless": true, 42 | "createdAt": 1659643229351, 43 | "updatedAt": 1659643229351, 44 | "deployHooks": [ 45 | { 46 | "createdAt": 1659707819534, 47 | "id": "MQmjZVn2NQ", 48 | "name": "Example Webhook", 49 | "ref": "main", 50 | "url": "https://api.vercel.com/" 51 | } 52 | ] 53 | }, 54 | "latestDeployments": [ 55 | { 56 | "alias": ["example.com", "example.vercel.app"], 57 | "aliasAssigned": 1659860877358, 58 | "aliasError": null, 59 | "builds": [], 60 | "createdAt": 1659860840191, 61 | "createdIn": "sfo1", 62 | "creator": { 63 | "uid": "ErNXfZNwbyDvjvkDpfbyqxqvA33W", 64 | "email": "dan.eden@me.com", 65 | "username": "daneden", 66 | "githubLogin": "daneden" 67 | }, 68 | "deploymentHostname": "example", 69 | "forced": false, 70 | "id": "dpl_ErNXfZNwbyDvjvkDpfbyqxqvA33W", 71 | "meta": { 72 | "githubCommitAuthorName": "Max Mayfield", 73 | "githubCommitMessage": "Example commit message", 74 | "githubCommitOrg": "example", 75 | "githubCommitRef": "main", 76 | "githubCommitRepo": "example-repo", 77 | "githubCommitSha": "ce79c4239488ecb16cfcbad767d5188ce131cee8", 78 | "githubDeployment": "1", 79 | "githubOrg": "example", 80 | "githubRepo": "example-repo", 81 | "githubCommitRepoId": "12345678", 82 | "githubRepoId": "12345678", 83 | "githubCommitAuthorLogin": "maxmay" 84 | }, 85 | "name": "example-project", 86 | "plan": "hobby", 87 | "private": true, 88 | "readyState": "READY", 89 | "target": "production", 90 | "teamId": null, 91 | "type": "LAMBDAS", 92 | "url": "example.vercel.app", 93 | "userId": "ErNXfZNwbyDvjvkDpfbyqxqvA33W", 94 | "withCache": false 95 | } 96 | ], 97 | "targets": { 98 | "production": { 99 | "alias": ["example.com", "example.vercel.app"], 100 | "aliasAssigned": 1659860877358, 101 | "aliasError": null, 102 | "builds": [], 103 | "createdAt": 1659860840191, 104 | "createdIn": "sfo1", 105 | "creator": { 106 | "uid": "ErNXfZNwbyDvjvkDpfbyqxqvA33W", 107 | "email": "dan.eden@me.com", 108 | "username": "daneden", 109 | "githubLogin": "daneden" 110 | }, 111 | "deploymentHostname": "example", 112 | "forced": false, 113 | "id": "dpl_ErNXfZNwbyDvjvkDpfbyqxqvA33W", 114 | "meta": { 115 | "githubCommitAuthorName": "Max Mayfield", 116 | "githubCommitMessage": "Set locale for dates to prevent mismatches between server and client renders", 117 | "githubCommitOrg": "daneden", 118 | "githubCommitRef": "main", 119 | "githubCommitRepo": "example-repo", 120 | "githubCommitSha": "ce79c4239488ecb16cfcbad767d5188ce131cee8", 121 | "githubDeployment": "1", 122 | "githubOrg": "daneden", 123 | "githubRepo": "example-repo", 124 | "githubCommitRepoId": "521398877", 125 | "githubRepoId": "521398877", 126 | "githubCommitAuthorLogin": "daneden" 127 | }, 128 | "name": "example-project", 129 | "plan": "hobby", 130 | "private": true, 131 | "readyState": "READY", 132 | "target": "production", 133 | "teamId": null, 134 | "type": "LAMBDAS", 135 | "url": "example.vercel.app", 136 | "userId": "ErNXfZNwbyDvjvkDpfbyqxqvA33W", 137 | "withCache": false 138 | } 139 | } 140 | } 141 | 142 | """.data(using: .utf8)! 143 | return try! JSONDecoder().decode(VercelProject.self, from: jsonData) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Zeitgeist.xcodeproj/xcshareddata/xcschemes/ZeitgeistWidgetsExtension.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 59 | 62 | 68 | 69 | 70 | 71 | 77 | 78 | 79 | 80 | 84 | 85 | 89 | 90 | 94 | 95 | 96 | 97 | 105 | 107 | 113 | 114 | 115 | 116 | 118 | 119 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /ZeitgeistWidgets/Views/RecentDeploymentsWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecentDeploymentsWidgetView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 31/05/2021. 6 | // Updated by Brad Bergeron on 22/11/2023. 7 | // 8 | 9 | import SwiftUI 10 | import WidgetKit 11 | 12 | // MARK: - RecentDeploymentsWidgetView 13 | 14 | struct RecentDeploymentsWidgetView: View { 15 | 16 | // MARK: Internal 17 | 18 | let config: RecentDeploymentsEntry 19 | 20 | var body: some View { 21 | Group { 22 | switch widgetFamily { 23 | case .systemSmall, .systemMedium: 24 | /// These sizes are unsupported by the widget. See ``RecentDeploymentsWidget`` for configuration. 25 | Color.clear 26 | case .systemLarge, .systemExtraLarge: 27 | systemView 28 | case .accessoryCircular, .accessoryRectangular, .accessoryInline: 29 | /// These sizes are unsupported by the widget. See ``RecentDeploymentsWidget`` for configuration. 30 | Color.clear 31 | @unknown default: 32 | Color.clear 33 | } 34 | } 35 | } 36 | 37 | // MARK: Private 38 | 39 | @Environment(\.dynamicTypeSize) private var dynamicTypeSize 40 | @Environment(\.widgetFamily) private var widgetFamily 41 | 42 | private var numberOfDeployments: Int { 43 | switch dynamicTypeSize { 44 | case .xSmall, 45 | .small, 46 | .medium, 47 | .large: 48 | return 5 49 | case .accessibility3, 50 | .accessibility4, 51 | .accessibility5: 52 | return 3 53 | default: 54 | return 4 55 | } 56 | } 57 | 58 | private var systemView: some View { 59 | VStack(alignment: .leading) { 60 | Label("Recent Deployments", systemImage: "clock") 61 | .font(.footnote.bold()) 62 | 63 | Spacer(minLength: 0) 64 | 65 | if let deployments = config.deployments?.prefix(numberOfDeployments) { 66 | ForEach(deployments) { deployment in 67 | Divider() 68 | Spacer(minLength: 0) 69 | RecentDeploymentsListRowView( 70 | accountId: config.account.identifier ?? "0", 71 | deployment: deployment, 72 | project: config.project 73 | ) 74 | Spacer(minLength: 0) 75 | } 76 | } else { 77 | VStack(alignment: .center) { 78 | Spacer(minLength: 0) 79 | PlaceholderView(forRole: .NoDeployments) 80 | .frame(maxWidth: .infinity) 81 | .font(.footnote) 82 | Spacer(minLength: 0) 83 | } 84 | } 85 | 86 | Spacer(minLength: 0) 87 | 88 | HStack { 89 | WidgetLabel(label: config.account.displayString, iconName: config.account.identifier?.isTeam == true ? "person.2" : "person") 90 | .symbolVariant(config.account.identifier == nil ? .none : .fill) 91 | 92 | Spacer() 93 | 94 | if let project = config.project, 95 | project.identifier != nil { 96 | WidgetLabel(label: project.displayString, iconName: "folder") 97 | } 98 | } 99 | .font(.caption) 100 | .foregroundStyle(.secondary) 101 | .lineLimit(1) 102 | } 103 | } 104 | 105 | } 106 | 107 | // MARK: - RecentDeploymentsListRowView 108 | 109 | struct RecentDeploymentsListRowView: View { 110 | 111 | // MARK: Internal 112 | 113 | let accountId: String 114 | let deployment: VercelDeployment 115 | let project: WidgetProject? 116 | 117 | var body: some View { 118 | Link(destination: URL(string: "zeitgeist://open/\(accountId)/\(deployment.id)")!) { 119 | Label { 120 | VStack(alignment: .leading) { 121 | Text(deployment.deploymentCause.description) 122 | .fontWeight(.bold) 123 | .foregroundStyle(.primary) 124 | 125 | HStack { 126 | if deployment.target == .production { 127 | Image(systemName: "theatermasks") 128 | .foregroundStyle(.tint) 129 | .symbolRenderingMode(.hierarchical) 130 | .symbolVariant(.fill) 131 | .imageScale(.small) 132 | .widgetAccentable() 133 | } 134 | 135 | if project?.identifier == nil { 136 | HStack { 137 | Text(deployment.project) 138 | Text(verbatim: "•") 139 | Text(deployment.created, style: .relative) 140 | } 141 | } else { 142 | Text(deployment.created, style: .relative) 143 | } 144 | } 145 | } 146 | .foregroundStyle(.secondary) 147 | .lineLimit(1) 148 | .imageScale(dynamicTypeSize.isAccessibilitySize ? .small : .medium) 149 | } icon: { 150 | DeploymentStateIndicator(state: deployment.state, style: .compact) 151 | } 152 | .font(.subheadline) 153 | .tint(.indigo) 154 | } 155 | } 156 | 157 | // MARK: Private 158 | 159 | @Environment(\.dynamicTypeSize) private var dynamicTypeSize 160 | 161 | } 162 | 163 | #if DEBUG 164 | 165 | struct RecentDeploymentsWidgetView_Previews: PreviewProvider { 166 | 167 | // MARK: Internal 168 | 169 | static var previews: some View { 170 | RecentDeploymentsWidgetView(config: .mockNoAccount) 171 | .previewContext(WidgetPreviewContext(family: widgetFamily)) 172 | .previewDisplayName("No Account") 173 | 174 | RecentDeploymentsWidgetView(config: .mockExample) 175 | .previewContext(WidgetPreviewContext(family: widgetFamily)) 176 | .previewDisplayName("Example") 177 | } 178 | 179 | // MARK: Private 180 | 181 | @Environment(\.widgetFamily) private static var widgetFamily 182 | 183 | } 184 | 185 | #endif 186 | -------------------------------------------------------------------------------- /Shared/Extensions/UIDevice+deviceInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIDevice+deviceInfo.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 08/01/2022. 6 | // 7 | // https://stackoverflow.com/questions/26028918/how-to-determine-the-current-iphone-device-model 8 | 9 | import Foundation 10 | 11 | #if canImport(UIKit) 12 | import UIKit 13 | 14 | public extension UIDevice { 15 | static let modelName: String = { 16 | var systemInfo = utsname() 17 | uname(&systemInfo) 18 | let machineMirror = Mirror(reflecting: systemInfo.machine) 19 | let identifier = machineMirror.children.reduce("") { identifier, element in 20 | guard let value = element.value as? Int8, value != 0 else { return identifier } 21 | return identifier + String(UnicodeScalar(UInt8(value))) 22 | } 23 | 24 | func mapToDevice(identifier: String) -> String { // swiftlint:disable:this cyclomatic_complexity 25 | #if os(iOS) 26 | switch identifier { 27 | case "iPod5,1": return "iPod touch (5th generation)" 28 | case "iPod7,1": return "iPod touch (6th generation)" 29 | case "iPod9,1": return "iPod touch (7th generation)" 30 | case "iPhone3,1", "iPhone3,2", "iPhone3,3": return "iPhone 4" 31 | case "iPhone4,1": return "iPhone 4s" 32 | case "iPhone5,1", "iPhone5,2": return "iPhone 5" 33 | case "iPhone5,3", "iPhone5,4": return "iPhone 5c" 34 | case "iPhone6,1", "iPhone6,2": return "iPhone 5s" 35 | case "iPhone7,2": return "iPhone 6" 36 | case "iPhone7,1": return "iPhone 6 Plus" 37 | case "iPhone8,1": return "iPhone 6s" 38 | case "iPhone8,2": return "iPhone 6s Plus" 39 | case "iPhone8,4": return "iPhone SE" 40 | case "iPhone9,1", "iPhone9,3": return "iPhone 7" 41 | case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus" 42 | case "iPhone10,1", "iPhone10,4": return "iPhone 8" 43 | case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus" 44 | case "iPhone10,3", "iPhone10,6": return "iPhone X" 45 | case "iPhone11,2": return "iPhone XS" 46 | case "iPhone11,4", "iPhone11,6": return "iPhone XS Max" 47 | case "iPhone11,8": return "iPhone XR" 48 | case "iPhone12,1": return "iPhone 11" 49 | case "iPhone12,3": return "iPhone 11 Pro" 50 | case "iPhone12,5": return "iPhone 11 Pro Max" 51 | case "iPhone12,8": return "iPhone SE (2nd generation)" 52 | case "iPhone13,1": return "iPhone 12 mini" 53 | case "iPhone13,2": return "iPhone 12" 54 | case "iPhone13,3": return "iPhone 12 Pro" 55 | case "iPhone13,4": return "iPhone 12 Pro Max" 56 | case "iPhone14,4": return "iPhone 13 mini" 57 | case "iPhone14,5": return "iPhone 13" 58 | case "iPhone14,2": return "iPhone 13 Pro" 59 | case "iPhone14,3": return "iPhone 13 Pro Max" 60 | case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return "iPad 2" 61 | case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad (3rd generation)" 62 | case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad (4th generation)" 63 | case "iPad6,11", "iPad6,12": return "iPad (5th generation)" 64 | case "iPad7,5", "iPad7,6": return "iPad (6th generation)" 65 | case "iPad7,11", "iPad7,12": return "iPad (7th generation)" 66 | case "iPad11,6", "iPad11,7": return "iPad (8th generation)" 67 | case "iPad12,1", "iPad12,2": return "iPad (9th generation)" 68 | case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air" 69 | case "iPad5,3", "iPad5,4": return "iPad Air 2" 70 | case "iPad11,3", "iPad11,4": return "iPad Air (3rd generation)" 71 | case "iPad13,1", "iPad13,2": return "iPad Air (4th generation)" 72 | case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad mini" 73 | case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad mini 2" 74 | case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad mini 3" 75 | case "iPad5,1", "iPad5,2": return "iPad mini 4" 76 | case "iPad11,1", "iPad11,2": return "iPad mini (5th generation)" 77 | case "iPad14,1", "iPad14,2": return "iPad mini (6th generation)" 78 | case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)" 79 | case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)" 80 | case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return "iPad Pro (11-inch) (1st generation)" 81 | case "iPad8,9", "iPad8,10": return "iPad Pro (11-inch) (2nd generation)" 82 | case "iPad13,4", "iPad13,5", "iPad13,6", "iPad13,7": return "iPad Pro (11-inch) (3rd generation)" 83 | case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch) (1st generation)" 84 | case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)" 85 | case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return "iPad Pro (12.9-inch) (3rd generation)" 86 | case "iPad8,11", "iPad8,12": return "iPad Pro (12.9-inch) (4th generation)" 87 | case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11": return "iPad Pro (12.9-inch) (5th generation)" 88 | case "AppleTV5,3": return "Apple TV" 89 | case "AppleTV6,2": return "Apple TV 4K" 90 | case "AudioAccessory1,1": return "HomePod" 91 | case "AudioAccessory5,1": return "HomePod mini" 92 | case "i386", "x86_64", "arm64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS"))" 93 | default: return identifier 94 | } 95 | #elseif os(tvOS) 96 | switch identifier { 97 | case "AppleTV5,3": return "Apple TV 4" 98 | case "AppleTV6,2": return "Apple TV 4K" 99 | case "i386", "x86_64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "tvOS"))" 100 | default: return identifier 101 | } 102 | #elseif os(visionOS) 103 | return identifier 104 | #endif 105 | } 106 | 107 | return mapToDevice(identifier: identifier) 108 | }() 109 | } 110 | #else 111 | 112 | #endif 113 | -------------------------------------------------------------------------------- /Shared/Models/GitCommit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitCommit.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 02/12/2020. 6 | // Copyright © 2020 Daniel Eden. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol GitCommit: Decodable { 12 | var provider: GitSVNProvider { get } 13 | var commitSha: String { get } 14 | var commitMessage: String { get } 15 | var commitRef: String { get } 16 | var commitAuthorName: String { get } 17 | var commitUrl: URL { get } 18 | var org: String { get } 19 | var repo: String { get } 20 | var repoId: String { get } 21 | var deployHookId: String? { get } 22 | var deployHookName: String? { get } 23 | var deployHookRef: String? { get } 24 | } 25 | 26 | extension GitCommit { 27 | var commitUrl: URL { 28 | switch provider { 29 | case .github: 30 | return URL(string: "https://github.com/\(org)/\(repo)/commit/\(commitSha)")! 31 | case .gitlab: 32 | return URL(string: "https://gitlab.com/\(org)/\(repo)/-/commit/\(commitSha)")! 33 | case .bitbucket: 34 | return URL(string: "https://bitbucket.com/\(org)/\(repo)/commits/\(commitSha)")! 35 | } 36 | } 37 | 38 | var shortSha: String { String(commitSha.prefix(8)) } 39 | 40 | var commitMessageSummary: String { 41 | commitMessage.components(separatedBy: "\n").first ?? "(Empty Commit Message)" 42 | } 43 | } 44 | 45 | struct GitHubCommit: Codable, GitCommit { 46 | let commitSha: String 47 | let commitMessage: String 48 | let commitAuthorName: String 49 | let commitRef: String 50 | let org: String 51 | let repo: String 52 | let repoId: String 53 | let deployHookId: String? 54 | let deployHookName: String? 55 | let deployHookRef: String? 56 | 57 | var provider: GitSVNProvider { .github } 58 | 59 | enum CodingKeys: String, CodingKey { 60 | case commitSha = "githubCommitSha" 61 | case commitMessage = "githubCommitMessage" 62 | case commitAuthorName = "githubCommitAuthorName" 63 | case commitRef = "githubCommitRef" 64 | case org = "githubCommitOrg" 65 | case repo = "githubCommitRepo" 66 | case repoId = "githubCommitRepoId" 67 | case deployHookName, deployHookRef, deployHookId 68 | } 69 | } 70 | 71 | struct GitLabCommit: Codable, GitCommit { 72 | var provider: GitSVNProvider { .gitlab } 73 | let commitSha: String 74 | let commitMessage: String 75 | let commitAuthorName: String 76 | let commitRef: String 77 | var org: String { projectPath.components(separatedBy: "/")[0] } 78 | var repo: String { projectPath.components(separatedBy: "/")[1] } 79 | let repoId: String 80 | let deployHookId: String? 81 | let deployHookName: String? 82 | let deployHookRef: String? 83 | 84 | private let projectPath: String 85 | 86 | enum CodingKeys: String, CodingKey { 87 | case commitSha = "gitlabCommitSha" 88 | case commitMessage = "gitlabCommitMessage" 89 | case commitAuthorName = "gitlabCommitAuthorName" 90 | case commitRef = "gitlabCommitRef" 91 | case projectPath = "gitlabProjectPath" 92 | case repoId = "gitlabProjectId" 93 | case deployHookName, deployHookRef, deployHookId 94 | } 95 | } 96 | 97 | struct BitBucketCommit: Codable, GitCommit { 98 | var provider: GitSVNProvider { .bitbucket } 99 | let commitSha: String 100 | let commitMessage: String 101 | let commitAuthorName: String 102 | let commitRef: String 103 | let repoId: String 104 | let workspaceId: String 105 | let org: String 106 | let repo: String 107 | let deployHookId: String? 108 | let deployHookName: String? 109 | let deployHookRef: String? 110 | 111 | enum CodingKeys: String, CodingKey { 112 | case commitSha = "bitbucketCommitSha" 113 | case commitMessage = "bitbucketCommitMessage" 114 | case commitAuthorName = "bitbucketCommitAuthorName" 115 | case commitRef = "bitbucketCommitRef" 116 | case org = "bitbucketRepoOwner" 117 | case repo = "bitbucketRepoSlug" 118 | case repoId = "bitbucketRepoUuid" 119 | case workspaceId = "bitbucketRepoWorkspaceUuid" 120 | case deployHookName, deployHookRef, deployHookId 121 | } 122 | } 123 | 124 | struct AnyCommit: Codable, GitCommit { 125 | var wrapped: GitCommit 126 | 127 | var provider: GitSVNProvider { wrapped.provider } 128 | var commitSha: String { wrapped.commitSha } 129 | var commitMessage: String { wrapped.commitMessage } 130 | var commitAuthorName: String { wrapped.commitAuthorName } 131 | var commitRef: String { wrapped.commitRef } 132 | var org: String { wrapped.org } 133 | var repo: String { wrapped.repo } 134 | var repoId: String { wrapped.repoId } 135 | var deployHookId: String? { wrapped.deployHookId } 136 | var deployHookName: String? { wrapped.deployHookName } 137 | var deployHookRef: String? { wrapped.deployHookRef } 138 | 139 | var action: Action? 140 | var originalDeploymentId: VercelDeployment.ID? 141 | 142 | enum CodingKeys: CodingKey { 143 | case action, originalDeploymentId 144 | } 145 | 146 | init(from decoder: Decoder) throws { 147 | let container = try decoder.container(keyedBy: CodingKeys.self) 148 | 149 | if let githubCommit = try? GitHubCommit(from: decoder) { 150 | wrapped = githubCommit 151 | } else if let gitlabCommit = try? GitLabCommit(from: decoder) { 152 | wrapped = gitlabCommit 153 | } else if let bitbucketCommit = try? BitBucketCommit(from: decoder) { 154 | wrapped = bitbucketCommit 155 | } else { 156 | throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unable to decode commit")) 157 | } 158 | 159 | action = try? container.decodeIfPresent(Action.self, forKey: .action) 160 | originalDeploymentId = try? container.decodeIfPresent(VercelDeployment.ID.self, forKey: .originalDeploymentId) 161 | } 162 | } 163 | 164 | extension AnyCommit { 165 | enum Action: String, Codable { 166 | case promote 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Shared/Views/Projects/ProjectDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProjectDetailView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 08/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Suite 10 | 11 | struct ProjectDetailView: View { 12 | @EnvironmentObject var session: VercelSession 13 | var projectId: VercelProject.ID 14 | @State var project: VercelProject? 15 | 16 | @State private var filter = DeploymentFilter() 17 | @State private var deployments: [VercelDeployment] = [] 18 | @State private var pagination: Pagination? 19 | @State private var projectNotificationsVisible = false 20 | 21 | @AppStorage(Preferences.deploymentNotificationIds) 22 | private var deploymentNotificationIds 23 | 24 | @AppStorage(Preferences.deploymentErrorNotificationIds) 25 | private var deploymentErrorNotificationIds 26 | 27 | @AppStorage(Preferences.deploymentReadyNotificationIds) 28 | private var deploymentReadyNotificationIds 29 | 30 | var notificationsEnabled: Bool { 31 | (deploymentNotificationIds + deploymentReadyNotificationIds + deploymentErrorNotificationIds) 32 | .contains { $0 == projectId } 33 | } 34 | 35 | var navBarTitle: Text { 36 | guard let name = project?.name else { 37 | return Text("Project details") 38 | } 39 | 40 | return Text(name) 41 | } 42 | 43 | var body: some View { 44 | Form { 45 | if let project { 46 | Section("Details") { 47 | LabelView(Text("Name")) { 48 | Text(project.name) 49 | } 50 | 51 | if let gitLink = project.link, 52 | let url = gitLink.repoUrl { 53 | let slug = gitLink.repoSlug 54 | let provider = gitLink.type 55 | 56 | LabelView(Text("Git repository")) { 57 | Link(destination: url) { 58 | Label(slug, image: provider.rawValue) 59 | } 60 | } 61 | 62 | LabelView(Text("Production branch")) { 63 | Text(gitLink.productionBranch) 64 | } 65 | 66 | NavigationLink(destination: ProjectEnvironmentVariablesView(projectId: project.id).environmentObject(session)) { 67 | Text("Environment variables") 68 | } 69 | } 70 | } 71 | 72 | if let productionDeployment = project.targets?.production { 73 | Section("Current Production Deployment") { 74 | NavigationLink { 75 | DeploymentDetailView(deploymentId: productionDeployment.id, deployment: productionDeployment) 76 | .id(productionDeployment.id) 77 | .environmentObject(session) 78 | } label: { 79 | DeploymentListRowView(deployment: productionDeployment) 80 | .id(productionDeployment.id) 81 | } 82 | } 83 | } 84 | 85 | Section("Recent deployments") { 86 | if filter.filtersApplied { 87 | Button { 88 | filter = .init() 89 | } label: { 90 | Label("Clear filters", systemImage: "xmark.circle") 91 | } 92 | } 93 | ForEach(deployments) { deployment in 94 | NavigationLink { 95 | DeploymentDetailView(deploymentId: deployment.id, deployment: deployment) 96 | .id(deployment.id) 97 | .environmentObject(session) 98 | } label: { 99 | DeploymentListRowView(deployment: deployment) 100 | .id(deployment.id) 101 | } 102 | } 103 | 104 | if deployments.isEmpty { 105 | LoadingListCell(title: "Loading deployments") 106 | } 107 | 108 | if let pageId = pagination?.next { 109 | LoadingListCell(title: "Loading deployments") 110 | .task { 111 | do { 112 | try await loadDeployments(pageId: pageId) 113 | } catch { 114 | print(error) 115 | } 116 | } 117 | } 118 | } 119 | } else { 120 | ProgressView() 121 | } 122 | } 123 | .toolbar { 124 | ToolbarItem { 125 | Button { 126 | projectNotificationsVisible = true 127 | } label: { 128 | Label("Notification settings", systemImage: notificationsEnabled ? "bell.badge" : "bell.slash") 129 | } 130 | } 131 | 132 | if #available(iOS 26, macOS 26, *) { 133 | ToolbarSpacer() 134 | } 135 | 136 | ToolbarItem { 137 | Menu { 138 | DeploymentFilterView(filter: $filter) 139 | } label: { 140 | Label("Filter deployments", systemImage: "line.3.horizontal.decrease") 141 | .backportCircleSymbolVariant() 142 | .symbolVariant(filter.filtersApplied ? .fill : .none) 143 | } 144 | } 145 | } 146 | .navigationTitle(navBarTitle) 147 | .onChange(of: filter) { _ in 148 | Task { 149 | try? await loadDeployments() 150 | } 151 | } 152 | .dataTask { 153 | do { 154 | try await initialLoad() 155 | } catch { 156 | print(error) 157 | } 158 | } 159 | .sheet(isPresented: $projectNotificationsVisible) { 160 | notificationsSheet 161 | } 162 | } 163 | 164 | @ViewBuilder 165 | var notificationsSheet: some View { 166 | if let project { 167 | Group { 168 | #if os(iOS) 169 | NavigationView { 170 | ProjectNotificationsView(project: project) 171 | } 172 | #else 173 | ProjectNotificationsView(project: project) 174 | #endif 175 | } 176 | .presentationDetents([.medium]) 177 | } 178 | } 179 | 180 | func initialLoad() async throws { 181 | try await loadDeployments() 182 | try await loadProject() 183 | } 184 | 185 | func loadProject() async throws { 186 | var request = VercelAPI.request(for: .projects(projectId), with: session.account.id) 187 | try session.signRequest(&request) 188 | 189 | let (data, _) = try await URLSession.shared.data(for: request) 190 | let projectResponse = try JSONDecoder().decode(VercelProject.self, from: data) 191 | 192 | withAnimation { 193 | self.project = projectResponse 194 | } 195 | } 196 | 197 | func loadDeployments(pageId: Int? = nil) async throws { 198 | var queryItems: [URLQueryItem] = [ 199 | URLQueryItem(name: "projectId", value: projectId), 200 | ] + filter.urlQueryItems 201 | 202 | if let pageId = pageId { 203 | queryItems.append(URLQueryItem(name: "from", value: String(pageId - 1))) 204 | } 205 | 206 | var request = VercelAPI.request(for: .deployments(), 207 | with: session.account.id, 208 | queryItems: queryItems) 209 | try session.signRequest(&request) 210 | 211 | if pageId == nil, 212 | let cachedResponse = URLCache.shared.cachedResponse(for: request), 213 | let decodedFromCache = try? JSONDecoder().decode(VercelDeployment.APIResponse.self, from: cachedResponse.data) 214 | { 215 | deployments = decodedFromCache.deployments 216 | } 217 | 218 | let (data, _) = try await URLSession.shared.data(for: request) 219 | let deploymentsResponse = try JSONDecoder().decode(VercelDeployment.APIResponse.self, from: data) 220 | 221 | withAnimation { 222 | if deployments.isEmpty || pageId == nil { 223 | deployments = deploymentsResponse.deployments 224 | } else { 225 | deployments += deploymentsResponse.deployments 226 | } 227 | 228 | pagination = deploymentsResponse.pagination 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /Shared/Views/Deployments/DeploymentLogView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeploymentLogView.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 09/01/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Suite 10 | 11 | fileprivate struct LogEntryMaxWidthPreferenceKey: PreferenceKey { 12 | static var defaultValue: CGFloat = 0 13 | 14 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 15 | value = max(value, nextValue()) 16 | } 17 | } 18 | 19 | struct LogEvent: Codable, Equatable, Identifiable { 20 | enum EventType: String, Codable { 21 | case command, stderr, stdout, delimiter, exit 22 | } 23 | 24 | struct DeploymentStateInfo: Codable { 25 | var name: String 26 | var readyState: VercelDeployment.State 27 | } 28 | 29 | struct Payload: Codable, Equatable { 30 | var id: String 31 | var text: String 32 | var date: TimeInterval 33 | var statusCode: Int? 34 | } 35 | 36 | var type: EventType 37 | var payload: Payload 38 | 39 | var id: String { payload.id } 40 | var date: Date { Date(timeIntervalSince1970: payload.date / 1000) } 41 | var text: String { payload.text } 42 | 43 | var outputColor: Color { 44 | switch type { 45 | case .stderr: 46 | if text.localizedCaseInsensitiveContains("warn") { 47 | return .orange 48 | } else { 49 | return .red 50 | } 51 | default: 52 | return .primary 53 | } 54 | } 55 | 56 | var backgroundStyle: AnyShapeStyle { 57 | switch type { 58 | case .stderr: return AnyShapeStyle(.quaternary) 59 | default: return AnyShapeStyle(.clear) 60 | } 61 | } 62 | } 63 | 64 | struct LogEventView: View { 65 | enum DisplayOption { 66 | case timestamp, log, both 67 | } 68 | 69 | @State private var logLineSize: CGSize = .zero 70 | 71 | var event: LogEvent 72 | var display: DisplayOption = .both 73 | 74 | var previousType: LogEvent.EventType? = nil 75 | var nextType: LogEvent.EventType? = nil 76 | 77 | private var cornerRadii: RectangleCornerRadii { 78 | let matchesPrev = previousType == event.type 79 | let matchesNext = nextType == event.type 80 | switch (matchesPrev, matchesNext) { 81 | case (false, false): 82 | return .init(topLeading: 4, bottomLeading: 4, bottomTrailing: 4, topTrailing: 4) 83 | case (false, true): 84 | return .init(topLeading: 4, topTrailing: 4) 85 | case (true, false): 86 | return .init(bottomLeading: 4, bottomTrailing: 4) 87 | case (true, true): 88 | return .init() 89 | } 90 | } 91 | 92 | var body: some View { 93 | HStack(alignment: .firstTextBaseline) { 94 | if display == .timestamp || display == .both { 95 | Text(event.date, style: .time) 96 | .foregroundStyle(.secondary) 97 | .fixedSize(horizontal: true, vertical: false) 98 | } 99 | 100 | if display == .log || display == .both { 101 | Text(event.text) 102 | .foregroundStyle(.primary) 103 | .fixedSize(horizontal: true, vertical: false) 104 | .frame(maxWidth: .infinity, alignment: .leading) 105 | } 106 | } 107 | .padding(.vertical, 2) 108 | .padding(.horizontal, 8) 109 | .preference(key: LogEntryMaxWidthPreferenceKey.self, value: logLineSize.width) 110 | .background(event.backgroundStyle, in: UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous)) 111 | .foregroundStyle(event.outputColor) 112 | .padding(.horizontal, -8) 113 | .scenePadding(.horizontal) 114 | .readSize($logLineSize) 115 | .transition(.opacity) 116 | } 117 | } 118 | 119 | struct DeploymentLogView: View { 120 | @EnvironmentObject private var session: VercelSession 121 | 122 | @AppStorage(Preferences.followLogs) var followLogs 123 | @State private var logEvents: [LogEvent] = [] 124 | @State private var maxLineWidth: CGFloat = 0 125 | 126 | var deployment: VercelDeployment 127 | 128 | var accountID: VercelAccount.ID { 129 | session.account.id 130 | } 131 | 132 | var body: some View { 133 | ScrollViewReader { proxy in 134 | GeometryReader { geometry in 135 | ScrollView([.horizontal, .vertical]) { 136 | LazyVStack(alignment: .leading, spacing: 0) { 137 | ForEach(Array(logEvents.enumerated()), id: \.element.id) { index, event in 138 | let prevType = index > 0 ? logEvents[index - 1].type : nil 139 | let nextType = index < logEvents.count - 1 ? logEvents[index + 1].type : nil 140 | LogEventView(event: event, previousType: prevType, nextType: nextType) 141 | .id(event.id) 142 | } 143 | } 144 | .animation(.default, value: logEvents) 145 | .frame(minWidth: maxLineWidth, minHeight: geometry.size.height, alignment: .topLeading) 146 | .textSelection(.enabled) 147 | .font(.footnote.monospaced()) 148 | .onPreferenceChange(LogEntryMaxWidthPreferenceKey.self) { width in 149 | maxLineWidth = width 150 | } 151 | } 152 | .modify { 153 | if #available(iOS 17, macOS 14, *) { 154 | $0.defaultScrollAnchor(followLogs ? .bottomLeading : .topLeading) 155 | } else { 156 | $0 157 | .task(id: logEvents.last?.id) { 158 | if followLogs, let latestEvent = logEvents.last { 159 | proxy.scrollTo(latestEvent.id, anchor: .bottomLeading) 160 | } 161 | } 162 | } 163 | } 164 | .toolbar { 165 | ToolbarItem { 166 | Toggle(isOn: $followLogs.animation()) { 167 | Label("Follow logs", systemImage: "arrow.down.to.line.compact") 168 | .padding(-4) 169 | .padding(.horizontal, -4) 170 | } 171 | .toggleStyle(.button) 172 | .disabled(logEvents.isEmpty) 173 | .onChange(of: followLogs) { _ in 174 | if followLogs { 175 | withAnimation { 176 | proxy.scrollTo(logEvents.last?.id, anchor: .bottomLeading) 177 | } 178 | } 179 | } 180 | } 181 | 182 | if #available(iOS 26, macOS 26, *) { 183 | ToolbarSpacer() 184 | } 185 | 186 | ToolbarItem { 187 | Link(destination: deployment.inspectorUrl) { 188 | Label("Open in browser", systemImage: "safari") 189 | } 190 | } 191 | } 192 | } 193 | } 194 | .overlay { 195 | if logEvents.isEmpty { 196 | ProgressView() 197 | } 198 | } 199 | .navigationTitle(Text("Build logs")) 200 | .task { 201 | do { 202 | let queryItems: [URLQueryItem] = [ 203 | URLQueryItem(name: "follow", value: "1"), 204 | URLQueryItem(name: "limit", value: "-1"), 205 | ] 206 | 207 | var request = VercelAPI.request( 208 | for: .deployments(version: 2, deploymentID: deployment.id, path: "events"), 209 | with: accountID, 210 | queryItems: queryItems 211 | ) 212 | try session.signRequest(&request) 213 | 214 | let (data, _) = try await URLSession.shared.bytes(for: request) 215 | 216 | for try await line in data.lines { 217 | if let lineAsData = line.data(using: .utf8), 218 | let event = try? JSONDecoder().decode(LogEvent.self, from: lineAsData) 219 | { 220 | logEvents.append(event) 221 | } 222 | } 223 | } catch { 224 | print(error.localizedDescription) 225 | } 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /Shared/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Zeitgeist 4 | // 5 | // Created by Daniel Eden on 05/06/2021. 6 | // 7 | 8 | 9 | import SwiftUI 10 | import OSLog 11 | 12 | #if canImport(WidgetKit) 13 | import WidgetKit 14 | #endif 15 | import UserNotifications 16 | 17 | #if canImport(UIKit) 18 | typealias RemoteNotificationResult = UIBackgroundFetchResult 19 | #elseif canImport(AppKit) 20 | enum RemoteNotificationResult { 21 | case newData, failed, noData 22 | } 23 | #endif 24 | 25 | #if DEBUG 26 | let platform = "ios_sandbox" 27 | #else 28 | let platform = "ios" 29 | #endif 30 | 31 | class AppDelegate: NSObject { 32 | @AppStorage(Preferences.authenticatedAccounts) 33 | private var authenticatedAccounts 34 | 35 | @AppStorage(Preferences.notificationEmoji) private var notificationEmoji 36 | @AppStorage(Preferences.notificationGrouping) private var notificationGrouping 37 | 38 | private static let logger = Logger( 39 | subsystem: Bundle.main.bundleIdentifier!, 40 | category: String(describing: AppDelegate.self) 41 | ) 42 | } 43 | 44 | #if canImport(UIKit) 45 | extension AppDelegate: UIApplicationDelegate { 46 | func application( 47 | _: UIApplication, 48 | didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil 49 | ) -> Bool { 50 | UIApplication.shared.registerForRemoteNotifications() 51 | 52 | UNUserNotificationCenter.current().delegate = self 53 | 54 | return true 55 | } 56 | 57 | func applicationWillEnterForeground(_: UIApplication) { 58 | UNUserNotificationCenter.current().removeAllDeliveredNotifications() 59 | } 60 | } 61 | 62 | extension AppDelegate: UNUserNotificationCenterDelegate { 63 | func application(_: UIApplication, 64 | didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 65 | registerDeviceTokenWithZPS(deviceToken) 66 | } 67 | 68 | func application(_: UIApplication, 69 | didFailToRegisterForRemoteNotificationsWithError error: Error) { 70 | print(error.localizedDescription) 71 | } 72 | 73 | func application(_: UIApplication, 74 | didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult { 75 | return await handleBackgroundNotification(userInfo) 76 | } 77 | } 78 | #elseif canImport(AppKit) 79 | extension AppDelegate: NSApplicationDelegate { 80 | func applicationDidFinishLaunching(_ notification: Notification) { 81 | NSApplication.shared.registerForRemoteNotifications() 82 | UNUserNotificationCenter.current().delegate = self 83 | } 84 | 85 | func applicationWillBecomeActive(_ notification: Notification) { 86 | UNUserNotificationCenter.current().removeAllDeliveredNotifications() 87 | } 88 | } 89 | 90 | extension AppDelegate: UNUserNotificationCenterDelegate { 91 | func application(_ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { 92 | print(error) 93 | } 94 | 95 | func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 96 | registerDeviceTokenWithZPS(deviceToken) 97 | } 98 | 99 | func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String : Any]) { 100 | Task { 101 | await handleBackgroundNotification(userInfo) 102 | } 103 | } 104 | } 105 | #endif 106 | 107 | extension AppDelegate { 108 | func userNotificationCenter(_: UNUserNotificationCenter, 109 | didReceive response: UNNotificationResponse) async { 110 | let userInfo = response.notification.request.content.userInfo 111 | 112 | guard let deploymentID = userInfo["DEPLOYMENT_ID"] as? String, 113 | let teamID = userInfo["TEAM_ID"] as? String else { return } 114 | 115 | switch response.notification.request.content.categoryIdentifier { 116 | case ZPSNotificationCategory.deployment.rawValue: 117 | #if canImport(UIKit) 118 | await UIApplication.shared.open(URL(string: "zeitgeist://deployment/\(teamID)/\(deploymentID)")!, options: [:]) 119 | #elseif canImport(AppKit) 120 | // Open deep link on macOS 121 | #endif 122 | default: 123 | Self.logger.warning("Uncaught notification category identifier: \(response.notification.request.content.categoryIdentifier, privacy: .public)") 124 | } 125 | } 126 | 127 | func registerDeviceTokenWithZPS(_ deviceToken: Data) { 128 | Self.logger.trace("Registered for remote notifications; registering in Zeitgeist Postal Service (ZPS)") 129 | 130 | authenticatedAccounts.forEach { account in 131 | let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() 132 | let url = URL(string: "https://zeitgeist.link/api/registerPushNotifications?user_id=\(account.id)&device_id=\(token)&platform=\(platform)")! 133 | let request = URLRequest(url: url) 134 | 135 | URLSession.shared.dataTask(with: request) { data, _, error in 136 | if let error = error { 137 | Self.logger.error("Error registering device ID to ZPS: \(error, privacy: .auto)") 138 | } 139 | 140 | if data != nil { 141 | Self.logger.notice("Successfully registered device ID \(token) to ZPS") 142 | } 143 | }.resume() 144 | } 145 | } 146 | 147 | @discardableResult 148 | func handleBackgroundNotification(_ userInfo: [AnyHashable: Any]) async -> RemoteNotificationResult { 149 | Self.logger.trace("Received remote notification") 150 | 151 | #if canImport(WidgetKit) 152 | WidgetCenter.shared.reloadAllTimelines() 153 | #endif 154 | 155 | await DataTaskModifier.postNotification(userInfo) 156 | 157 | do { 158 | let title = userInfo["title"] as? String 159 | guard let body = userInfo["body"] as? String else { 160 | throw ZPSError.FieldCastingError(field: userInfo["body"]) 161 | } 162 | 163 | guard let projectId = userInfo["projectId"] as? String else { 164 | throw ZPSError.FieldCastingError(field: userInfo["projectId"]) 165 | } 166 | 167 | let deploymentId: String? = userInfo["deploymentId"] as? String 168 | let teamId: String? = userInfo["teamId"] as? String 169 | let userId: String? = userInfo["userId"] as? String 170 | 171 | guard let accountId = teamId ?? userId, 172 | Preferences.accounts.contains(where: { $0.id == accountId }) else { 173 | return .noData 174 | } 175 | 176 | let target: String? = userInfo["target"] as? String 177 | 178 | guard let eventType: ZPSEventType = ZPSEventType(rawValue: userInfo["eventType"] as? String ?? "") else { 179 | throw ZPSError.EventTypeCastingError(eventType: userInfo["eventType"]) 180 | } 181 | 182 | guard NotificationManager.userAllowedNotifications( 183 | for: eventType, 184 | with: projectId, 185 | target: VercelDeployment.Target(rawValue: target ?? "") 186 | ) else { 187 | Self.logger.notice("Notification suppressed due to user preferences") 188 | return .newData 189 | } 190 | 191 | let content = UNMutableNotificationContent() 192 | 193 | if let title = title { 194 | content.title = title 195 | content.body = body 196 | } else { 197 | content.title = body 198 | } 199 | 200 | if notificationEmoji { 201 | content.title = "\(eventType.emojiPrefix)\(content.title)" 202 | } 203 | 204 | content.sound = .default 205 | 206 | switch notificationGrouping { 207 | case .account: 208 | content.threadIdentifier = teamId ?? userId ?? "accountForProject-\(projectId)" 209 | case .project: 210 | content.threadIdentifier = projectId 211 | case .deployment: 212 | content.threadIdentifier = deploymentId ?? projectId 213 | } 214 | 215 | content.categoryIdentifier = eventType.rawValue 216 | content.userInfo = [ 217 | "DEPLOYMENT_ID": "\(deploymentId ?? "nil")", 218 | "TEAM_ID": "\(teamId ?? "-1")", 219 | "PROJECT_ID": "\(projectId)", 220 | ] 221 | 222 | let notificationID = "\(content.threadIdentifier)-\(eventType.rawValue)" 223 | 224 | let request = UNNotificationRequest(identifier: notificationID, content: content, trigger: nil) 225 | Self.logger.notice("Pushing notification with ID \(notificationID)") 226 | try await UNUserNotificationCenter.current().add(request) 227 | return .newData 228 | } catch { 229 | switch error { 230 | case let ZPSError.FieldCastingError(field): 231 | Self.logger.error("Notification failed with field casting error: \(field.debugDescription, privacy: .public)") 232 | case let ZPSError.EventTypeCastingError(eventType): 233 | Self.logger.error("Notification failed with event type casting error: \(eventType.debugDescription)") 234 | default: 235 | Self.logger.error("Unknown error occured when handling background notification") 236 | } 237 | 238 | Self.logger.error("Error details: \(error.localizedDescription, privacy: .public)") 239 | 240 | return .failed 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /Shared/Models/VercelDeployment.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct VercelDeployment: Identifiable, Hashable, Decodable { 5 | var isMockDeployment: Bool? 6 | var project: String 7 | var id: String 8 | var target: Target? 9 | 10 | private var createdAt: Int = .init(Date().timeIntervalSince1970) / 1000 11 | private var buildingAt: Int? 12 | private var ready: Int? 13 | 14 | var readyAt: Date? { 15 | guard let ready else { return nil } 16 | return Date(timeIntervalSince1970: TimeInterval(ready / 1000)) 17 | } 18 | 19 | var building: Date? { 20 | guard let buildingAt else { return nil } 21 | return Date(timeIntervalSince1970: TimeInterval(buildingAt)) 22 | } 23 | 24 | var created: Date { 25 | return Date(timeIntervalSince1970: TimeInterval(createdAt / 1000)) 26 | } 27 | 28 | var updated: Date { 29 | if let ready = ready { 30 | return Date(timeIntervalSince1970: TimeInterval(ready / 1000)) 31 | } else { 32 | return created 33 | } 34 | } 35 | 36 | var state: State 37 | private var urlString: String = "vercel.com" 38 | private var inspectorUrlString: String = "vercel.com" 39 | 40 | var url: URL { 41 | URL(string: "https://\(urlString)")! 42 | } 43 | 44 | var inspectorUrl: URL { 45 | URL(string: "https://\(inspectorUrlString)")! 46 | } 47 | 48 | var commit: AnyCommit? 49 | var creator: CreatorOverview? 50 | var team: TeamOverview? 51 | var teamId: String? 52 | 53 | var deploymentCause: DeploymentCause { 54 | guard let commit = commit else { return .manual } 55 | 56 | if let action = commit.action { 57 | switch action { 58 | case .promote: 59 | return .promotion(originalDeploymentId: commit.originalDeploymentId) 60 | } 61 | } else if let deploymentHookName = commit.deployHookName { 62 | return .deployHook(name: deploymentHookName) 63 | } else { 64 | return .gitCommit(commit: commit) 65 | } 66 | } 67 | 68 | func hash(into hasher: inout Hasher) { 69 | hasher.combine(id) 70 | hasher.combine(state) 71 | } 72 | 73 | enum CodingKeys: String, CodingKey { 74 | case project = "name" 75 | case urlString = "url" 76 | case createdAt = "created" 77 | case createdAtFallback = "createdAt" 78 | case commit = "meta" 79 | case inspectorUrlString = "inspectorUrl" 80 | case buildingAt = "buildingAt" 81 | 82 | case state, creator, target, readyState, ready, uid, id, teamId, team 83 | } 84 | 85 | init(from decoder: Decoder) throws { 86 | let container = try decoder.container(keyedBy: CodingKeys.self) 87 | project = try container.decode(String.self, forKey: .project) 88 | state = try container.decodeIfPresent(VercelDeployment.State.self, forKey: .readyState) ?? container.decode(VercelDeployment.State.self, forKey: .state) 89 | urlString = try container.decode(String.self, forKey: .urlString) 90 | createdAt = try container.decodeIfPresent(Int.self, forKey: .createdAtFallback) ?? container.decode(Int.self, forKey: .createdAt) 91 | buildingAt = try container.decodeIfPresent(Int.self, forKey: .buildingAt) 92 | id = try container.decodeIfPresent(String.self, forKey: .uid) ?? container.decode(String.self, forKey: .id) 93 | commit = try? container.decode(AnyCommit.self, forKey: .commit) 94 | target = try? container.decode(VercelDeployment.Target.self, forKey: .target) 95 | inspectorUrlString = try container.decodeIfPresent(String.self, forKey: .inspectorUrlString) ?? "\(urlString)/_logs" 96 | team = try? container.decodeIfPresent(TeamOverview.self, forKey: .team) 97 | teamId = try? container.decodeIfPresent(String.self, forKey: .teamId) 98 | creator = try container.decodeIfPresent(CreatorOverview.self, forKey: .creator) 99 | ready = try container.decodeIfPresent(Int.self, forKey: .ready) 100 | } 101 | 102 | static func == (lhs: VercelDeployment, rhs: VercelDeployment) -> Bool { 103 | return lhs.id == rhs.id && lhs.state == rhs.state 104 | } 105 | 106 | init(asMockDeployment: Bool) throws { 107 | guard asMockDeployment == true else { 108 | throw DeploymentError.MockDeploymentInitError 109 | } 110 | 111 | project = "Example Project" 112 | state = .allCases.randomElement()! 113 | urlString = "zeitgeist.daneden.me" 114 | createdAt = Int(Date().timeIntervalSince1970 * 1000) 115 | id = "0000" 116 | target = VercelDeployment.Target.staging 117 | creator = VercelDeployment.CreatorOverview(uid: UUID().uuidString, username: "Test Account", githubLogin: nil) 118 | } 119 | 120 | enum DeploymentError: Error { 121 | case MockDeploymentInitError 122 | } 123 | } 124 | 125 | extension VercelDeployment { 126 | enum State: String, Codable, CaseIterable { 127 | case ready = "READY" 128 | case queued = "QUEUED" 129 | case error = "ERROR" 130 | case building = "BUILDING" 131 | case normal = "NORMAL" 132 | case offline = "OFFLINE" 133 | case cancelled = "CANCELED" 134 | case initializing = "INITIALIZING" 135 | 136 | static var typicalCases: [VercelDeployment.State] { 137 | return Self.allCases.filter { state in 138 | state != .normal && state != .offline && state != .initializing 139 | } 140 | } 141 | 142 | var description: LocalizedStringKey { 143 | switch self { 144 | case .error: 145 | return "Error building" 146 | case .building: 147 | return "Building" 148 | case .ready: 149 | return "Deployed" 150 | case .queued: 151 | return "Queued" 152 | case .cancelled: 153 | return "Cancelled" 154 | case .offline: 155 | return "Offline" 156 | default: 157 | return "Ready" 158 | } 159 | } 160 | } 161 | 162 | enum DeploymentCause: Codable { 163 | case deployHook(name: String) 164 | case gitCommit(commit: AnyCommit) 165 | case promotion(originalDeploymentId: VercelDeployment.ID?) 166 | case manual 167 | 168 | var description: String { 169 | switch self { 170 | case let .gitCommit(commit): 171 | return commit.commitMessageSummary 172 | case let .deployHook(name): 173 | return name 174 | case .promotion(_): 175 | return "Production rebuild" 176 | case .manual: 177 | return "Manual deployment" 178 | } 179 | } 180 | 181 | var icon: String? { 182 | switch self { 183 | case let .gitCommit(commit): 184 | return commit.provider.rawValue 185 | case .deployHook: 186 | return "hook" 187 | case .promotion(_): 188 | return "arrow.up.circle" 189 | case .manual: 190 | return nil 191 | } 192 | } 193 | } 194 | 195 | enum Target: String, Codable, CaseIterable { 196 | case production, staging 197 | 198 | var description: LocalizedStringKey { 199 | switch self { 200 | case .production: 201 | return "Production" 202 | case .staging: 203 | return "Staging" 204 | } 205 | } 206 | } 207 | } 208 | 209 | extension VercelDeployment { 210 | struct CreatorOverview: Codable, Identifiable { 211 | var id: ID { uid } 212 | let uid: String 213 | let username: String 214 | let githubLogin: String? 215 | } 216 | 217 | struct TeamOverview: Codable, Identifiable { 218 | let id: String 219 | let name: String? 220 | let slug: String 221 | } 222 | 223 | var unwrappedTeamId: String? { 224 | teamId ?? team?.id 225 | } 226 | } 227 | 228 | extension VercelDeployment { 229 | struct APIResponse: Decodable { 230 | let deployments: [VercelDeployment] 231 | let pagination: Pagination? 232 | } 233 | } 234 | 235 | extension VercelDeployment.State { 236 | var imageName: String { 237 | switch self { 238 | case .error: 239 | return "exclamationmark.triangle" 240 | case .queued: 241 | return "square.stack.3d.up" 242 | case .building: 243 | return "timer" 244 | case .ready: 245 | return "checkmark.circle" 246 | case .cancelled: 247 | return "nosign" 248 | case .offline: 249 | return "wifi.slash" 250 | default: 251 | return "arrowtriangle.up.circle" 252 | } 253 | } 254 | 255 | var color: Color { 256 | switch self { 257 | case .error: 258 | return .red 259 | case .building: 260 | return .purple 261 | case .ready: 262 | return .green 263 | case .cancelled: 264 | return .primary 265 | default: 266 | return .gray 267 | } 268 | } 269 | } 270 | 271 | extension VercelDeployment { 272 | var promoteToProductionDataPayload: Data? { 273 | let dataDict: [String: Any] = [ 274 | "deploymentId": id, 275 | "meta": [ 276 | "action": "promote" 277 | ], 278 | "name": project, 279 | "target": "production" 280 | ] 281 | 282 | return try? JSONSerialization.data(withJSONObject: dataDict) 283 | } 284 | 285 | var redeployDataPayload: Data? { 286 | guard let commit else { 287 | return nil 288 | } 289 | 290 | var gitSource: [String: String?] = [ 291 | "ref": commit.commitRef, 292 | "sha": commit.commitSha, 293 | "type": commit.provider.rawValue, 294 | ] 295 | 296 | var dataDict: [String: Any] = [ 297 | "name": project 298 | ] 299 | 300 | if target == .production { 301 | dataDict["target"] = "production" 302 | } 303 | 304 | switch commit.provider { 305 | case .github: 306 | gitSource["repoId"] = commit.repoId 307 | gitSource["prId"] = nil 308 | case .bitbucket: 309 | gitSource["owner"] = commit.org 310 | gitSource["slug"] = commit.repo 311 | gitSource["workspaceUuid"] = (commit.wrapped as? BitBucketCommit)?.workspaceId 312 | gitSource["repoUuid"] = commit.repoId 313 | case .gitlab: 314 | gitSource["projectId"] = commit.repoId 315 | } 316 | 317 | dataDict["gitSource"] = gitSource 318 | 319 | return try? JSONSerialization.data(withJSONObject: dataDict) 320 | } 321 | } 322 | --------------------------------------------------------------------------------