├── .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 |
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 |
--------------------------------------------------------------------------------