├── app-icon.png
├── Resources
├── AppIcon.icns
├── vercel-icon.svg
└── entitlements.plist
├── docs
├── robots.txt
├── img
│ └── vercel-menu-bar-deployment-status-macos.png
├── sitemap.xml
├── style.css
└── index.html
├── vercel.json
├── vercel-menu-bar-deployment-status-macos.png
├── .gitignore
├── Sources
└── vercel-deployment-menu-bar
│ ├── AppDelegate.swift
│ ├── Models.swift
│ ├── DeploymentService.swift
│ ├── PreferencesWindow.swift
│ └── StatusItemController.swift
├── Package.swift
├── LICENSE
├── Scripts
├── generate-icon.py
├── notarize-app.sh
├── package-app.sh
└── create-app-icon.sh
└── README.md
/app-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewk17/vercel-deployment-menu-bar/HEAD/app-icon.png
--------------------------------------------------------------------------------
/Resources/AppIcon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewk17/vercel-deployment-menu-bar/HEAD/Resources/AppIcon.icns
--------------------------------------------------------------------------------
/docs/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow:
3 | Sitemap: https://vercel-deployment-menu-bar.vercel.app/sitemap.xml
4 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": null,
3 | "outputDirectory": "docs",
4 | "cleanUrls": true,
5 | "trailingSlash": false
6 | }
7 |
--------------------------------------------------------------------------------
/vercel-menu-bar-deployment-status-macos.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewk17/vercel-deployment-menu-bar/HEAD/vercel-menu-bar-deployment-status-macos.png
--------------------------------------------------------------------------------
/docs/img/vercel-menu-bar-deployment-status-macos.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewk17/vercel-deployment-menu-bar/HEAD/docs/img/vercel-menu-bar-deployment-status-macos.png
--------------------------------------------------------------------------------
/Resources/vercel-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /build
4 | /Packages
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/configuration/registries.json
8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9 | .netrc
10 | *.xcodeproj
11 | *.xcworkspace
12 |
13 | # IDE
14 | .idea/
15 | .vscode/
16 |
--------------------------------------------------------------------------------
/docs/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | https://vercel-deployment-menu-bar.vercel.app/
5 | 2025-01-16
6 | weekly
7 | 1.0
8 |
9 |
10 |
--------------------------------------------------------------------------------
/Resources/entitlements.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.network.client
8 |
9 | com.apple.security.network.server
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Sources/vercel-deployment-menu-bar/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 |
3 | @main
4 | final class AppDelegate: NSObject, NSApplicationDelegate {
5 | private var statusController: StatusItemController?
6 |
7 | static func main() {
8 | let app = NSApplication.shared
9 | let delegate = AppDelegate()
10 | app.delegate = delegate
11 | app.setActivationPolicy(.accessory)
12 | app.run()
13 | }
14 |
15 | func applicationDidFinishLaunching(_ notification: Notification) {
16 | statusController = StatusItemController()
17 | statusController?.start()
18 | }
19 |
20 | func applicationWillTerminate(_ notification: Notification) {
21 | statusController?.stop()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "vercel-deployment-menu-bar",
8 | platforms: [
9 | .macOS(.v13)
10 | ],
11 | targets: [
12 | // Targets are the basic building blocks of a package, defining a module or a test suite.
13 | // Targets can depend on other targets in this package and products from dependencies.
14 | .executableTarget(
15 | name: "vercel-deployment-menu-bar",
16 | linkerSettings: [
17 | .linkedFramework("AppKit"),
18 | .linkedFramework("SwiftUI")
19 | ]
20 | ),
21 | ]
22 | )
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Scripts/generate-icon.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import os
3 | from pathlib import Path
4 |
5 | # SVG content for upside-down triangle
6 | svg_template = '''
7 |
8 |
9 | '''
10 |
11 | # Icon sizes needed for macOS
12 | sizes = [16, 32, 64, 128, 256, 512, 1024]
13 |
14 | root = Path(__file__).parent.parent
15 | iconset_dir = root / "Resources" / "AppIcon.iconset"
16 | iconset_dir.mkdir(parents=True, exist_ok=True)
17 |
18 | for size in sizes:
19 | # Calculate triangle points (upside-down)
20 | center_x = size / 2
21 | top_y = size * 0.25
22 | bottom_y = size * 0.75
23 | left_x = size * 0.2
24 | right_x = size * 0.8
25 |
26 | # Create SVG
27 | svg_content = svg_template.format(
28 | size=size,
29 | x1=center_x, y1=bottom_y, # Bottom point
30 | x2=left_x, y2=top_y, # Top left
31 | x3=right_x, y3=top_y # Top right
32 | )
33 |
34 | # Write SVG files
35 | if size <= 512:
36 | svg_path = iconset_dir / f"icon_{size}x{size}.svg"
37 | with open(svg_path, 'w') as f:
38 | f.write(svg_content)
39 |
40 | # For @2x versions
41 | if size >= 32 and size <= 512:
42 | svg_path = iconset_dir / f"icon_{size//2}x{size//2}@2x.svg"
43 | with open(svg_path, 'w') as f:
44 | f.write(svg_content)
45 |
46 | print(f"SVG icons created in {iconset_dir}")
47 | print("Note: You'll need to convert these to PNG manually using a tool like Inkscape or ImageMagick")
48 |
--------------------------------------------------------------------------------
/Scripts/notarize-app.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 |
4 | # This script notarizes the built app bundle with Apple's notary service
5 | #
6 | # Prerequisites:
7 | # 1. Create an App Store Connect API key:
8 | # - Go to https://appstoreconnect.apple.com/access/api
9 | # - Create a key with "Developer" role
10 | # - Download the .p8 file and save it to ~/.private_keys/
11 | #
12 | # 2. Set environment variables (add to ~/.zshrc or ~/.bash_profile):
13 | # export APPLE_API_KEY_ID="your-key-id"
14 | # export APPLE_API_ISSUER="your-issuer-id"
15 | # export APPLE_API_KEY_PATH="$HOME/.private_keys/AuthKey_XXXXXXXXXX.p8"
16 |
17 | ROOT=$(cd "$(dirname "$0")"/.. && pwd)
18 | PRODUCT_NAME="Vercel Deployment Menu Bar"
19 | APP_DIR="$ROOT/build/${PRODUCT_NAME}.app"
20 |
21 | # Check if environment variables are set
22 | if [ -z "${APPLE_API_KEY_ID:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ] || [ -z "${APPLE_API_KEY_PATH:-}" ]; then
23 | echo "⚠️ Notarization skipped: App Store Connect API credentials not configured"
24 | echo ""
25 | echo "To enable notarization, set these environment variables:"
26 | echo " APPLE_API_KEY_ID - Your API Key ID"
27 | echo " APPLE_API_ISSUER - Your Issuer ID"
28 | echo " APPLE_API_KEY_PATH - Path to your .p8 file"
29 | echo ""
30 | echo "See the script header for detailed setup instructions."
31 | exit 0
32 | fi
33 |
34 | # Create a ZIP for notarization
35 | ZIP_PATH="$ROOT/build/${PRODUCT_NAME}.zip"
36 | echo "Creating ZIP for notarization..."
37 | ditto -c -k --keepParent "$APP_DIR" "$ZIP_PATH"
38 |
39 | echo "Submitting to Apple's notary service..."
40 | xcrun notarytool submit "$ZIP_PATH" \
41 | --key "$APPLE_API_KEY_PATH" \
42 | --key-id "$APPLE_API_KEY_ID" \
43 | --issuer "$APPLE_API_ISSUER" \
44 | --wait
45 |
46 | echo "Stapling notarization ticket to app..."
47 | xcrun stapler staple "$APP_DIR"
48 |
49 | echo "Verifying notarization..."
50 | xcrun stapler validate "$APP_DIR"
51 |
52 | # Clean up ZIP
53 | rm "$ZIP_PATH"
54 |
55 | echo "✅ App successfully notarized and stapled!"
56 |
--------------------------------------------------------------------------------
/Scripts/package-app.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | ROOT=$(cd "$(dirname "$0")"/.. && pwd)
4 | PRODUCT_NAME="Vercel Deployment Menu Bar"
5 | BUILD_DIR="$ROOT/.build/release"
6 | EXECUTABLE="$BUILD_DIR/vercel-deployment-menu-bar"
7 | APP_DIR="$ROOT/build/${PRODUCT_NAME}.app"
8 |
9 | rm -rf "$APP_DIR"
10 | mkdir -p "$APP_DIR/Contents/MacOS"
11 | mkdir -p "$APP_DIR/Contents/Resources"
12 |
13 | cat > "$APP_DIR/Contents/Info.plist" <<'INFO'
14 |
15 |
16 |
17 |
18 | CFBundleDisplayName
19 | Vercel Deployment Menu Bar
20 | CFBundleExecutable
21 | vercel-deployment-menu-bar
22 | CFBundleIconFile
23 | AppIcon
24 | CFBundleIdentifier
25 | com.andrew.vercel-deployment-menu-bar
26 | CFBundleName
27 | Vercel Deployment Menu Bar
28 | CFBundlePackageType
29 | APPL
30 | CFBundleShortVersionString
31 | 0.1.0
32 | CFBundleVersion
33 | 1
34 | LSMinimumSystemVersion
35 | 13.0
36 | LSUIElement
37 |
38 | NSPrincipalClass
39 | NSApplication
40 |
41 |
42 | INFO
43 |
44 | cp "$EXECUTABLE" "$APP_DIR/Contents/MacOS/"
45 | chmod +x "$APP_DIR/Contents/MacOS/vercel-deployment-menu-bar"
46 |
47 | # Copy app icon if it exists
48 | if [ -f "$ROOT/Resources/AppIcon.icns" ]; then
49 | cp "$ROOT/Resources/AppIcon.icns" "$APP_DIR/Contents/Resources/"
50 | fi
51 |
52 | # Sign the app with Developer ID certificate
53 | SIGNING_IDENTITY="Developer ID Application: Andrew Kim (P44RGE92LU)"
54 | ENTITLEMENTS="$ROOT/Resources/entitlements.plist"
55 |
56 | echo "Signing app with identity: $SIGNING_IDENTITY"
57 | codesign --force --deep --options runtime \
58 | --entitlements "$ENTITLEMENTS" \
59 | --sign "$SIGNING_IDENTITY" \
60 | --timestamp \
61 | "$APP_DIR"
62 |
63 | # Verify the signature
64 | codesign --verify --deep --strict --verbose=2 "$APP_DIR"
65 |
66 | printf 'App bundle created at %s\n' "$APP_DIR"
67 |
68 | # Notarize the app (if credentials are configured)
69 | "$ROOT/Scripts/notarize-app.sh"
70 |
--------------------------------------------------------------------------------
/Scripts/create-app-icon.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 |
4 | ROOT=$(cd "$(dirname "$0")"/.. && pwd)
5 | ICONSET_DIR="$ROOT/Resources/AppIcon.iconset"
6 | mkdir -p "$ICONSET_DIR"
7 |
8 | # Create a simple upside-down triangle using Swift
9 | cat > /tmp/create_icon.swift <<'SWIFT'
10 | import AppKit
11 |
12 | let size = 1024
13 | let image = NSImage(size: NSSize(width: size, height: size))
14 | image.lockFocus()
15 |
16 | // Flip coordinate system to match image orientation (top-left origin)
17 | let transform = NSAffineTransform()
18 | transform.translateX(by: 0, yBy: CGFloat(size))
19 | transform.scaleX(by: 1.0, yBy: -1.0)
20 | transform.concat()
21 |
22 | // Black background for better visibility
23 | NSColor.black.setFill()
24 | NSRect(x: 0, y: 0, width: size, height: size).fill()
25 |
26 | // White upside-down triangle (point down)
27 | let path = NSBezierPath()
28 | let centerX = CGFloat(size) / 2.0
29 | let topY = CGFloat(size) * 0.25 // Flat edge at top
30 | let bottomY = CGFloat(size) * 0.75 // Point at bottom
31 | let leftX = CGFloat(size) * 0.2
32 | let rightX = CGFloat(size) * 0.8
33 |
34 | // For upside-down: flat edge on top, point at bottom
35 | path.move(to: NSPoint(x: leftX, y: topY)) // Top left corner
36 | path.line(to: NSPoint(x: rightX, y: topY)) // Top right corner
37 | path.line(to: NSPoint(x: centerX, y: bottomY)) // Bottom point
38 | path.close()
39 |
40 | NSColor.white.setFill()
41 | path.fill()
42 |
43 | image.unlockFocus()
44 |
45 | // Save as PNG
46 | if let tiffData = image.tiffRepresentation,
47 | let bitmapImage = NSBitmapImageRep(data: tiffData),
48 | let pngData = bitmapImage.representation(using: .png, properties: [:]) {
49 | let url = URL(fileURLWithPath: "/tmp/icon_1024.png")
50 | try? pngData.write(to: url)
51 | print("Created base icon at /tmp/icon_1024.png")
52 | }
53 | SWIFT
54 |
55 | # Compile and run Swift script
56 | swiftc -o /tmp/create_icon /tmp/create_icon.swift -framework AppKit
57 | /tmp/create_icon
58 |
59 | # Create all required sizes using sips
60 | sips -z 16 16 /tmp/icon_1024.png --out "$ICONSET_DIR/icon_16x16.png" > /dev/null
61 | sips -z 32 32 /tmp/icon_1024.png --out "$ICONSET_DIR/icon_16x16@2x.png" > /dev/null
62 | sips -z 32 32 /tmp/icon_1024.png --out "$ICONSET_DIR/icon_32x32.png" > /dev/null
63 | sips -z 64 64 /tmp/icon_1024.png --out "$ICONSET_DIR/icon_32x32@2x.png" > /dev/null
64 | sips -z 128 128 /tmp/icon_1024.png --out "$ICONSET_DIR/icon_128x128.png" > /dev/null
65 | sips -z 256 256 /tmp/icon_1024.png --out "$ICONSET_DIR/icon_128x128@2x.png" > /dev/null
66 | sips -z 256 256 /tmp/icon_1024.png --out "$ICONSET_DIR/icon_256x256.png" > /dev/null
67 | sips -z 512 512 /tmp/icon_1024.png --out "$ICONSET_DIR/icon_256x256@2x.png" > /dev/null
68 | sips -z 512 512 /tmp/icon_1024.png --out "$ICONSET_DIR/icon_512x512.png" > /dev/null
69 | cp /tmp/icon_1024.png "$ICONSET_DIR/icon_512x512@2x.png"
70 |
71 | # Convert iconset to icns
72 | iconutil -c icns "$ICONSET_DIR" -o "$ROOT/Resources/AppIcon.icns"
73 |
74 | echo "App icon created at $ROOT/Resources/AppIcon.icns"
75 |
76 | # Clean up
77 | rm -rf "$ICONSET_DIR"
78 | rm /tmp/icon_1024.png /tmp/create_icon.swift /tmp/create_icon
79 |
--------------------------------------------------------------------------------
/Sources/vercel-deployment-menu-bar/Models.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct Preferences: Codable, Equatable {
4 | var vercelToken: String
5 | var teamId: String
6 | var projectName: String
7 | var gitBranches: String
8 | var showProduction: Bool
9 | var showPreview: Bool
10 | var showReady: Bool
11 | var showBuilding: Bool
12 | var showError: Bool
13 | var showQueued: Bool
14 | var showCanceled: Bool
15 | var limitByCount: Int?
16 | var limitByHours: Int?
17 | var refreshIntervalIdle: Int?
18 | var refreshIntervalBuilding: Int?
19 |
20 | static let `default` = Preferences(
21 | vercelToken: "",
22 | teamId: "",
23 | projectName: "",
24 | gitBranches: "",
25 | showProduction: true,
26 | showPreview: true,
27 | showReady: true,
28 | showBuilding: true,
29 | showError: true,
30 | showQueued: true,
31 | showCanceled: true,
32 | limitByCount: 5,
33 | limitByHours: nil,
34 | refreshIntervalIdle: 15,
35 | refreshIntervalBuilding: 2
36 | )
37 |
38 | var hasToken: Bool {
39 | !vercelToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
40 | }
41 |
42 | var branchList: [String] {
43 | gitBranches
44 | .split(separator: ",")
45 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
46 | .filter { !$0.isEmpty }
47 | }
48 | }
49 |
50 | final class PreferencesStore {
51 | static let shared = PreferencesStore()
52 |
53 | static let didChangeNotification = Notification.Name("PreferencesStoreDidChange")
54 |
55 | private let storageKey = "vercelStatusPreferences"
56 | private let userDefaults: UserDefaults
57 |
58 | private(set) var current: Preferences {
59 | didSet {
60 | persist()
61 | NotificationCenter.default.post(name: Self.didChangeNotification, object: current)
62 | }
63 | }
64 |
65 | private init(userDefaults: UserDefaults = .standard) {
66 | self.userDefaults = userDefaults
67 | if
68 | let data = userDefaults.data(forKey: storageKey),
69 | let decoded = try? JSONDecoder().decode(Preferences.self, from: data)
70 | {
71 | current = decoded
72 | } else {
73 | current = .default
74 | }
75 | }
76 |
77 | func update(_ transform: (inout Preferences) -> Void) {
78 | var updated = current
79 | transform(&updated)
80 | current = updated
81 | }
82 |
83 | func save(_ preferences: Preferences) {
84 | current = preferences
85 | }
86 |
87 | private func persist() {
88 | if let data = try? JSONEncoder().encode(current) {
89 | userDefaults.set(data, forKey: storageKey)
90 | }
91 | }
92 | }
93 |
94 | struct Team: Decodable {
95 | let id: String
96 | let slug: String
97 | let name: String
98 | }
99 |
100 | struct TeamsResponse: Decodable {
101 | let teams: [Team]
102 | }
103 |
104 | struct Deployment: Decodable {
105 | enum State: String, Decodable {
106 | case building = "BUILDING"
107 | case error = "ERROR"
108 | case ready = "READY"
109 | case queued = "QUEUED"
110 | case canceled = "CANCELED"
111 | case unknown
112 |
113 | init(from decoder: Decoder) throws {
114 | let container = try decoder.singleValueContainer()
115 | let rawValue = try container.decode(String.self)
116 | self = State(rawValue: rawValue) ?? .unknown
117 | }
118 | }
119 |
120 | struct Creator: Decodable {
121 | let username: String?
122 | }
123 |
124 | struct Meta: Decodable {
125 | let githubCommitMessage: String?
126 | let githubCommitRef: String?
127 | }
128 |
129 | struct GitSource: Decodable {
130 | let ref: String?
131 | let type: String?
132 | }
133 |
134 | let uid: String
135 | let name: String
136 | let url: String
137 | let created: TimeInterval
138 | let state: State
139 | let ready: TimeInterval?
140 | let buildingAt: TimeInterval?
141 | let target: String?
142 | let creator: Creator
143 | let meta: Meta?
144 | let gitSource: GitSource?
145 |
146 | enum CodingKeys: String, CodingKey {
147 | case uid
148 | case name
149 | case url
150 | case created
151 | case state
152 | case ready
153 | case buildingAt
154 | case target
155 | case creator
156 | case meta
157 | case gitSource
158 | }
159 |
160 | var createdDate: Date {
161 | Date(timeIntervalSince1970: created / 1000)
162 | }
163 |
164 | var readyDate: Date? {
165 | ready.map { Date(timeIntervalSince1970: $0 / 1000) }
166 | }
167 |
168 | var buildingAtDate: Date {
169 | let timestamp = buildingAt ?? created
170 | return Date(timeIntervalSince1970: timestamp / 1000)
171 | }
172 | }
173 |
174 | struct DeploymentsResponse: Decodable {
175 | let deployments: [Deployment]
176 | }
177 |
178 | enum APIError: LocalizedError {
179 | case missingToken
180 | case invalidResponse(status: Int, message: String)
181 | case decodingFailure
182 |
183 | var errorDescription: String? {
184 | switch self {
185 | case .missingToken:
186 | return "Vercel token is missing. Please update Preferences."
187 | case let .invalidResponse(status, message):
188 | return "Vercel API error (\(status)): \(message)"
189 | case .decodingFailure:
190 | return "Failed to decode response from Vercel."
191 | }
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/Sources/vercel-deployment-menu-bar/DeploymentService.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | final class DeploymentService {
4 | private let session: URLSession
5 |
6 | init(session: URLSession = .shared) {
7 | self.session = session
8 | }
9 |
10 | func fetchAllDeployments(preferences: Preferences) async throws -> [Deployment] {
11 | guard preferences.hasToken else {
12 | throw APIError.missingToken
13 | }
14 |
15 | // If team is explicitly provided, fetch only for that team.
16 | if !preferences.teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
17 | return try await fetchDeployments(
18 | token: preferences.vercelToken,
19 | teamId: preferences.teamId,
20 | projectName: emptyToNil(preferences.projectName),
21 | limit: 100
22 | )
23 | }
24 |
25 | // Otherwise, attempt to fetch across user + teams matching Raycast behavior.
26 | do {
27 | let teams = try await fetchTeams(token: preferences.vercelToken)
28 | async let personalDeployments: [Deployment] = fetchDeployments(
29 | token: preferences.vercelToken,
30 | teamId: nil,
31 | projectName: emptyToNil(preferences.projectName),
32 | limit: 100
33 | )
34 |
35 | let teamDeployments = try await withThrowingTaskGroup(of: [Deployment].self) { group -> [[Deployment]] in
36 | for team in teams {
37 | group.addTask {
38 | do {
39 | return try await self.fetchDeployments(
40 | token: preferences.vercelToken,
41 | teamId: team.id,
42 | projectName: emptyToNil(preferences.projectName),
43 | limit: 100
44 | )
45 | } catch {
46 | return []
47 | }
48 | }
49 | }
50 |
51 | var all: [[Deployment]] = []
52 | for try await deployments in group {
53 | all.append(deployments)
54 | }
55 | return all
56 | }
57 |
58 | let combined = try await personalDeployments + teamDeployments.flatMap { $0 }
59 | return combined.sorted { $0.created > $1.created }
60 | } catch {
61 | // If team fetch fails (scoped token), fallback to fetching without team.
62 | return try await fetchDeployments(
63 | token: preferences.vercelToken,
64 | teamId: nil,
65 | projectName: emptyToNil(preferences.projectName),
66 | limit: 100
67 | )
68 | }
69 | }
70 |
71 | private func fetchTeams(token: String) async throws -> [Team] {
72 | guard let url = URL(string: "https://api.vercel.com/v2/teams") else {
73 | return []
74 | }
75 |
76 | var request = URLRequest(url: url)
77 | request.httpMethod = "GET"
78 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
79 | request.setValue("application/json", forHTTPHeaderField: "Content-Type")
80 |
81 | let (data, response) = try await session.data(for: request)
82 | guard let httpResponse = response as? HTTPURLResponse else {
83 | throw APIError.invalidResponse(status: -1, message: "No response")
84 | }
85 |
86 | guard (200...299).contains(httpResponse.statusCode) else {
87 | let message = String(data: data, encoding: .utf8) ?? "Unknown error"
88 | throw APIError.invalidResponse(status: httpResponse.statusCode, message: message)
89 | }
90 |
91 | guard let decoded = try? JSONDecoder().decode(TeamsResponse.self, from: data) else {
92 | throw APIError.decodingFailure
93 | }
94 | return decoded.teams
95 | }
96 |
97 | private func fetchDeployments(
98 | token: String,
99 | teamId: String?,
100 | projectName: String?,
101 | limit: Int
102 | ) async throws -> [Deployment] {
103 | var components = URLComponents(string: "https://api.vercel.com/v6/deployments")!
104 | var queryItems = [
105 | URLQueryItem(name: "limit", value: "\(limit)")
106 | ]
107 | if let teamId, !teamId.isEmpty {
108 | queryItems.append(URLQueryItem(name: "teamId", value: teamId))
109 | }
110 | if let projectName, !projectName.isEmpty {
111 | queryItems.append(URLQueryItem(name: "app", value: projectName))
112 | }
113 | components.queryItems = queryItems
114 |
115 | guard let url = components.url else {
116 | return []
117 | }
118 |
119 | var request = URLRequest(url: url)
120 | request.httpMethod = "GET"
121 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
122 | request.setValue("application/json", forHTTPHeaderField: "Content-Type")
123 |
124 | let (data, response) = try await session.data(for: request)
125 | guard let httpResponse = response as? HTTPURLResponse else {
126 | throw APIError.invalidResponse(status: -1, message: "No response")
127 | }
128 |
129 | guard (200...299).contains(httpResponse.statusCode) else {
130 | let message = String(data: data, encoding: .utf8) ?? "Unknown error"
131 | throw APIError.invalidResponse(status: httpResponse.statusCode, message: message)
132 | }
133 |
134 | guard let decoded = try? JSONDecoder().decode(DeploymentsResponse.self, from: data) else {
135 | throw APIError.decodingFailure
136 | }
137 | return decoded.deployments
138 | }
139 | }
140 |
141 | private func emptyToNil(_ string: String) -> String? {
142 | let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
143 | return trimmed.isEmpty ? nil : trimmed
144 | }
145 |
--------------------------------------------------------------------------------
/docs/style.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | box-sizing: border-box;
5 | }
6 |
7 | body {
8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
9 | line-height: 1.6;
10 | color: #333;
11 | background: #fff;
12 | }
13 |
14 | .container {
15 | max-width: 1200px;
16 | margin: 0 auto;
17 | padding: 0 20px;
18 | }
19 |
20 | /* Header */
21 | header {
22 | padding: 20px 0;
23 | border-bottom: 1px solid #eee;
24 | }
25 |
26 | nav {
27 | display: flex;
28 | justify-content: flex-end;
29 | }
30 |
31 | .github-link {
32 | color: #333;
33 | text-decoration: none;
34 | font-weight: 500;
35 | padding: 8px 16px;
36 | border: 1px solid #ddd;
37 | border-radius: 6px;
38 | transition: all 0.2s;
39 | }
40 |
41 | .github-link:hover {
42 | background: #f6f8fa;
43 | border-color: #333;
44 | }
45 |
46 | /* Hero Section */
47 | .hero {
48 | padding: 80px 0 60px;
49 | text-align: center;
50 | }
51 |
52 | .hero h1 {
53 | font-size: 48px;
54 | font-weight: 700;
55 | line-height: 1.2;
56 | margin-bottom: 20px;
57 | color: #000;
58 | }
59 |
60 | .subhead {
61 | font-size: 20px;
62 | color: #666;
63 | margin-bottom: 40px;
64 | max-width: 700px;
65 | margin-left: auto;
66 | margin-right: auto;
67 | }
68 |
69 | .cta-buttons {
70 | display: flex;
71 | gap: 16px;
72 | justify-content: center;
73 | flex-wrap: wrap;
74 | }
75 |
76 | .btn {
77 | display: inline-block;
78 | padding: 14px 32px;
79 | font-size: 16px;
80 | font-weight: 600;
81 | text-decoration: none;
82 | border-radius: 8px;
83 | transition: all 0.2s;
84 | }
85 |
86 | .btn-primary {
87 | background: #000;
88 | color: #fff;
89 | }
90 |
91 | .btn-primary:hover {
92 | background: #333;
93 | transform: translateY(-2px);
94 | }
95 |
96 | .btn-secondary {
97 | background: #fff;
98 | color: #000;
99 | border: 1px solid #ddd;
100 | }
101 |
102 | .btn-secondary:hover {
103 | background: #f6f8fa;
104 | border-color: #333;
105 | transform: translateY(-2px);
106 | }
107 |
108 | /* Screenshot Section */
109 | .screenshot-section {
110 | padding: 40px 0 80px;
111 | background: #f6f8fa;
112 | }
113 |
114 | .main-screenshot {
115 | width: 100%;
116 | max-width: 800px;
117 | height: auto;
118 | display: block;
119 | margin: 0 auto;
120 | border-radius: 12px;
121 | box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
122 | }
123 |
124 | /* Features Section */
125 | .features {
126 | padding: 80px 0;
127 | }
128 |
129 | .features h2 {
130 | font-size: 36px;
131 | text-align: center;
132 | margin-bottom: 50px;
133 | }
134 |
135 | .feature-grid {
136 | display: grid;
137 | grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
138 | gap: 40px;
139 | }
140 |
141 | .feature h3 {
142 | font-size: 20px;
143 | margin-bottom: 12px;
144 | color: #000;
145 | }
146 |
147 | .feature p {
148 | color: #666;
149 | line-height: 1.7;
150 | }
151 |
152 | /* How-to Section */
153 | .how-to {
154 | padding: 80px 0;
155 | background: #f6f8fa;
156 | }
157 |
158 | .how-to h2 {
159 | font-size: 36px;
160 | text-align: center;
161 | margin-bottom: 50px;
162 | }
163 |
164 | .step {
165 | margin-bottom: 40px;
166 | }
167 |
168 | .step h3 {
169 | font-size: 24px;
170 | margin-bottom: 12px;
171 | color: #000;
172 | }
173 |
174 | .step p {
175 | color: #666;
176 | line-height: 1.7;
177 | margin-bottom: 12px;
178 | }
179 |
180 | .step ol {
181 | margin-left: 20px;
182 | color: #666;
183 | line-height: 1.7;
184 | }
185 |
186 | .step ol li {
187 | margin-bottom: 8px;
188 | }
189 |
190 | .step code {
191 | background: #fff;
192 | padding: 2px 6px;
193 | border-radius: 4px;
194 | font-family: 'Monaco', 'Courier New', monospace;
195 | font-size: 14px;
196 | color: #333;
197 | border: 1px solid #ddd;
198 | }
199 |
200 | .step a {
201 | color: #0070f3;
202 | text-decoration: none;
203 | }
204 |
205 | .step a:hover {
206 | text-decoration: underline;
207 | }
208 |
209 | /* Requirements Section */
210 | .requirements {
211 | padding: 80px 0;
212 | }
213 |
214 | .requirements h2 {
215 | font-size: 36px;
216 | text-align: center;
217 | margin-bottom: 30px;
218 | }
219 |
220 | .requirements ul {
221 | max-width: 600px;
222 | margin: 0 auto;
223 | list-style: none;
224 | }
225 |
226 | .requirements li {
227 | padding: 12px 0;
228 | padding-left: 30px;
229 | position: relative;
230 | color: #666;
231 | line-height: 1.7;
232 | }
233 |
234 | .requirements li:before {
235 | content: "✓";
236 | position: absolute;
237 | left: 0;
238 | color: #0070f3;
239 | font-weight: bold;
240 | }
241 |
242 | /* Open Source Section */
243 | .open-source {
244 | padding: 80px 0;
245 | background: #f6f8fa;
246 | text-align: center;
247 | }
248 |
249 | .open-source h2 {
250 | font-size: 36px;
251 | margin-bottom: 20px;
252 | }
253 |
254 | .open-source p {
255 | font-size: 18px;
256 | color: #666;
257 | max-width: 700px;
258 | margin: 0 auto;
259 | }
260 |
261 | .open-source a {
262 | color: #0070f3;
263 | text-decoration: none;
264 | }
265 |
266 | .open-source a:hover {
267 | text-decoration: underline;
268 | }
269 |
270 | /* Footer */
271 | footer {
272 | padding: 40px 0;
273 | border-top: 1px solid #eee;
274 | text-align: center;
275 | color: #666;
276 | }
277 |
278 | footer p {
279 | margin-bottom: 10px;
280 | }
281 |
282 | footer a {
283 | color: #0070f3;
284 | text-decoration: none;
285 | }
286 |
287 | footer a:hover {
288 | text-decoration: underline;
289 | }
290 |
291 | .disclaimer {
292 | font-size: 14px;
293 | font-style: italic;
294 | }
295 |
296 | /* Responsive */
297 | @media (max-width: 768px) {
298 | .hero h1 {
299 | font-size: 36px;
300 | }
301 |
302 | .subhead {
303 | font-size: 18px;
304 | }
305 |
306 | .features h2,
307 | .how-to h2,
308 | .requirements h2,
309 | .open-source h2 {
310 | font-size: 28px;
311 | }
312 |
313 | .feature-grid {
314 | grid-template-columns: 1fr;
315 | }
316 | }
317 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Vercel Deployment Menu Bar for macOS (Open Source)
2 |
3 | A lightweight **macOS menu bar app** to **monitor Vercel deployments** in real time — see build/ready/error status at a glance and jump to details with one click.
4 |
5 | 
6 |
7 | **Features**
8 | - Real-time deployment status (build, ready, error)
9 | - Personal or Team tokens
10 | - Notarized, code-signed app (no Gatekeeper warnings)
11 | - Native Swift app — lightweight and fast
12 | - Quick access to deployment details from the menu bar
13 |
14 | ## Installation
15 |
16 | ### Pre-built Binary (Easiest)
17 |
18 | 1. Download the latest release from the [Releases](https://github.com/andrewk17/vercel-deployment-menu-bar/releases) page
19 | 2. Unzip and move the app to your Applications folder
20 | 3. Launch the app
21 | 4. Click the menu bar icon and select "Preferences" to configure your Vercel API token
22 |
23 | ### Build from Source
24 |
25 | Requirements:
26 | - macOS 13.0 or later
27 | - Xcode 15.0 or later
28 | - Swift 5.9 or later
29 |
30 | ```bash
31 | # Clone the repository
32 | git clone https://github.com/andrewk17/vercel-deployment-menu-bar.git
33 | cd vercel-deployment-menu-bar
34 |
35 | # Build the app
36 | swift build -c release
37 |
38 | # Package as .app bundle (creates a signed and notarized app)
39 | ./Scripts/package-app.sh
40 |
41 | # The app will be created at: build/Vercel Deployment Menu Bar.app
42 | ```
43 |
44 | #### For Developers: Code Signing & Notarization
45 |
46 | The app is properly code signed and notarized to prevent macOS Gatekeeper warnings. If you're building for distribution:
47 |
48 | 1. **Prerequisites:**
49 | - Apple Developer account ($99/year)
50 | - Developer ID Application certificate installed
51 | - App Store Connect API key for notarization
52 |
53 | 2. **Setup App Store Connect API Key:**
54 | ```bash
55 | # Create directory for API key
56 | mkdir -p ~/.private_keys
57 |
58 | # Download your .p8 file from https://appstoreconnect.apple.com/access/api
59 | # Move it to ~/.private_keys/
60 |
61 | # Add to ~/.zshrc or ~/.bash_profile:
62 | export APPLE_API_KEY_ID="your-key-id"
63 | export APPLE_API_ISSUER="your-issuer-id"
64 | export APPLE_API_KEY_PATH="$HOME/.private_keys/AuthKey_XXXXXXXXXX.p8"
65 | ```
66 |
67 | 3. **Build and notarize:**
68 | ```bash
69 | ./Scripts/package-app.sh
70 | # The script will automatically sign and notarize the app
71 | ```
72 |
73 | The build script will:
74 | - Sign the app with your Developer ID certificate
75 | - Submit to Apple's notary service
76 | - Staple the notarization ticket
77 | - Verify the signature
78 |
79 | If notarization credentials aren't configured, the script will still sign the app but skip notarization.
80 |
81 | ## Configuration
82 |
83 | ### Step 1: Generate a Vercel API Token
84 |
85 | 1. Go to [Vercel Account Settings → Tokens](https://vercel.com/account/tokens)
86 | 2. Click "Create Token"
87 | 3. Give your token a name (e.g., "Menu Bar App")
88 | 4. Choose the scope:
89 | - **Personal Account**: Select your personal account scope
90 | - **Team Account**: Select the specific team you want to monitor
91 | 5. Set an expiration date (optional but recommended)
92 | 6. Click "Create Token"
93 | 7. **Important**: Copy the token immediately - you won't be able to see it again!
94 |
95 | ### Step 2: Configure the App
96 |
97 | 1. Launch "Vercel Deployment Menu Bar" from your Applications folder
98 | 2. Click the menu bar icon (upside-down triangle)
99 | 3. Select "Preferences"
100 | 4. Enter your API token in the "Token" field
101 |
102 | ### Step 3: Configure Team ID (Only if you scoped the token to a team)
103 |
104 | If you created a token scoped to a specific team, you **must** also enter your Team ID:
105 |
106 | 1. In the Preferences window, locate the "Team ID" field
107 | 2. To find your Team ID:
108 | - Go to your [Vercel Dashboard](https://vercel.com/)
109 | - Select your team from the dropdown
110 | - Look at the URL - it will be: `https://vercel.com/[TEAM_ID]/~`
111 | - The `[TEAM_ID]` is what you need (e.g., if the URL is `https://vercel.com/acme-corp/~`, your Team ID is `acme-corp`)
112 | - Alternatively, go to Team Settings → General and find your Team Slug
113 | 3. Enter the Team ID in the preferences
114 | 4. Click save
115 |
116 | **Note**: If you used a personal account token, you can leave the Team ID field empty.
117 |
118 | ### Step 4: Start Monitoring
119 |
120 | Once configured, the app will automatically start monitoring your deployments. The menu bar icon will update based on your latest deployment status.
121 |
122 | ## How It Works
123 |
124 | The app uses the Vercel API to:
125 | 1. Fetch your deployment list periodically
126 | 2. Check the status of each deployment
127 | 3. Update the menu bar icon based on deployment states
128 | 4. Display deployment details in a convenient menu
129 |
130 | ## Requirements
131 |
132 | - macOS 13.0+
133 | - Vercel API token
134 |
135 | ## License
136 |
137 | MIT License - see [LICENSE](LICENSE) file for details
138 |
139 | ## FAQ
140 |
141 | ### How do I monitor Vercel deployments from the macOS menu bar?
142 |
143 | Install the Vercel Deployment Menu Bar app, configure it with your Vercel API token, and it will automatically display real-time deployment status in your macOS menu bar. The app polls the Vercel API and updates the status icon based on your latest deployments.
144 |
145 | ### Where do I find my Vercel Team ID?
146 |
147 | If you're using a team-scoped Vercel API token, you need to provide your Team ID:
148 |
149 | 1. Go to your [Vercel Dashboard](https://vercel.com/)
150 | 2. Select your team from the dropdown
151 | 3. Look at the URL: `https://vercel.com/[TEAM_ID]/~`
152 | 4. Copy the `[TEAM_ID]` portion (e.g., if the URL shows `https://vercel.com/acme-corp/~`, your Team ID is `acme-corp`)
153 | 5. Alternatively, go to Team Settings → General to find your Team Slug
154 |
155 | Enter this Team ID in the app's Preferences. If you're using a personal account token, you can leave the Team ID field empty.
156 |
157 | ### How do I fix the "damaged app" error on macOS?
158 |
159 | The latest releases (v0.2.0+) are properly code-signed and notarized, so you shouldn't see this error. If you do:
160 |
161 | 1. Download the latest release from the [Releases](https://github.com/andrewshawcare/vercel-deployment-menu-bar/releases) page
162 | 2. If the error persists, right-click the app and select "Open" instead of double-clicking
163 | 3. For older versions, you may need to remove the quarantine attribute: `xattr -d com.apple.quarantine "/Applications/Vercel Deployment Menu Bar.app"`
164 |
165 | ## Contributing
166 |
167 | Contributions are welcome! Please feel free to submit a Pull Request.
168 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Vercel Deployment Menu Bar for macOS - Monitor Vercel Deployments
9 |
10 |
11 |
12 |
28 |
29 |
30 |
37 |
38 |
39 |
40 |
41 |
Monitor Vercel deployments from your macOS menu bar
42 |
Free & open source. Real-time build/ready/error status. Works with personal & team tokens.
43 |
44 |
48 |
49 |
50 |
51 |
52 |
53 |
56 |
57 |
58 |
59 |
60 |
61 |
Features
62 |
63 |
64 |
Real-time Monitoring
65 |
Continuously monitors your Vercel deployments and updates the menu bar icon based on deployment states.
66 |
67 |
68 |
Visual Indicators
69 |
Clear status icons for building, ready, error, and other deployment states at a glance.
70 |
71 |
72 |
Personal & Team Support
73 |
Works with both personal Vercel accounts and team deployments using team-scoped tokens.
74 |
75 |
76 |
Lightweight & Native
77 |
Built with Swift and SwiftUI for optimal macOS performance. Minimal resource usage.
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
How to Get Started
86 |
87 |
88 |
1. Download and Install
89 |
Download the latest release, unzip it, and move to your Applications folder. The app is properly code-signed and notarized.
90 |
91 |
92 |
93 |
2. Generate a Vercel API Token
94 |
Go to Vercel Account Settings → Tokens and create a new Vercel API token. Choose either personal account or team scope for monitoring your Vercel deployments.
95 |
96 |
97 |
98 |
3. Configure the App
99 |
Launch the app, click the menu bar icon, select "Preferences", and enter your API token. If using a team token, also enter your Team ID.
100 |
101 |
102 |
103 |
Where do I find my Vercel Team ID?
104 |
If you're using a team-scoped token:
105 |
106 | Go to your Vercel Dashboard
107 | Select your team from the dropdown
108 | Look at the URL: https://vercel.com/[TEAM_ID]/~
109 | The [TEAM_ID] is what you need (e.g., if the URL shows https://vercel.com/acme-corp/~, your Team ID is acme-corp)
110 |
111 |
For personal accounts, leave the Team ID field empty.
112 |
113 |
114 |
115 |
116 |
117 |
118 |
Requirements
119 |
120 | macOS 13.0 or later
121 | Vercel API token (free to create)
122 |
123 |
124 |
125 |
126 |
127 |
128 |
Open Source
129 |
Vercel Deployment Menu Bar is open source under the MIT License. View the source code, report issues, or contribute on GitHub .
130 |
131 |
132 |
133 |
134 |
140 |
141 |
142 |
--------------------------------------------------------------------------------
/Sources/vercel-deployment-menu-bar/PreferencesWindow.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 | import SwiftUI
3 |
4 | final class PreferencesWindowController: NSWindowController {
5 | static let shared = PreferencesWindowController()
6 |
7 | private let viewModel: PreferencesViewModel
8 |
9 | private init() {
10 | let viewModel = PreferencesViewModel(store: PreferencesStore.shared)
11 | self.viewModel = viewModel
12 | let view = PreferencesView(viewModel: viewModel)
13 | let hostingController = NSHostingController(rootView: view)
14 | let window = NSWindow(
15 | contentRect: NSRect(x: 0, y: 0, width: 500, height: 680),
16 | styleMask: [.titled, .closable, .miniaturizable],
17 | backing: .buffered,
18 | defer: false
19 | )
20 | window.center()
21 | window.title = "Vercel Status Preferences"
22 | window.contentViewController = hostingController
23 | super.init(window: window)
24 | self.window?.delegate = self
25 | }
26 |
27 | @available(*, unavailable)
28 | required init?(coder: NSCoder) {
29 | fatalError("init(coder:) has not been implemented")
30 | }
31 |
32 | func show() {
33 | guard let window else { return }
34 | // Refresh the view model with latest stored preferences when opening.
35 | viewModel.reset()
36 | window.makeKeyAndOrderFront(nil)
37 | NSApp.activate(ignoringOtherApps: true)
38 | }
39 | }
40 |
41 | extension PreferencesWindowController: NSWindowDelegate {
42 | func windowWillClose(_ notification: Notification) {
43 | viewModel.saveAndClose()
44 | }
45 | }
46 |
47 | final class PreferencesViewModel: ObservableObject {
48 | @Published var vercelToken: String
49 | @Published var teamId: String
50 | @Published var projectName: String
51 | @Published var gitBranches: String
52 | @Published var showProduction: Bool
53 | @Published var showPreview: Bool
54 | @Published var showReady: Bool
55 | @Published var showBuilding: Bool
56 | @Published var showError: Bool
57 | @Published var showQueued: Bool
58 | @Published var showCanceled: Bool
59 | @Published var limitByCount: String
60 | @Published var limitByHours: String
61 | @Published var refreshIntervalIdle: String
62 | @Published var refreshIntervalBuilding: String
63 |
64 | private let store: PreferencesStore
65 |
66 | init(store: PreferencesStore) {
67 | self.store = store
68 | let current = store.current
69 |
70 | vercelToken = current.vercelToken
71 | teamId = current.teamId
72 | projectName = current.projectName
73 | gitBranches = current.gitBranches
74 | showProduction = current.showProduction
75 | showPreview = current.showPreview
76 | showReady = current.showReady
77 | showBuilding = current.showBuilding
78 | showError = current.showError
79 | showQueued = current.showQueued
80 | showCanceled = current.showCanceled
81 | limitByCount = current.limitByCount.map(String.init) ?? ""
82 | limitByHours = current.limitByHours.map(String.init) ?? ""
83 | refreshIntervalIdle = current.refreshIntervalIdle.map(String.init) ?? ""
84 | refreshIntervalBuilding = current.refreshIntervalBuilding.map(String.init) ?? ""
85 | }
86 |
87 | func reset() {
88 | let current = store.current
89 | vercelToken = current.vercelToken
90 | teamId = current.teamId
91 | projectName = current.projectName
92 | gitBranches = current.gitBranches
93 | showProduction = current.showProduction
94 | showPreview = current.showPreview
95 | showReady = current.showReady
96 | showBuilding = current.showBuilding
97 | showError = current.showError
98 | showQueued = current.showQueued
99 | showCanceled = current.showCanceled
100 | limitByCount = current.limitByCount.map(String.init) ?? ""
101 | limitByHours = current.limitByHours.map(String.init) ?? ""
102 | refreshIntervalIdle = current.refreshIntervalIdle.map(String.init) ?? ""
103 | refreshIntervalBuilding = current.refreshIntervalBuilding.map(String.init) ?? ""
104 | }
105 |
106 | func saveAndClose() {
107 | store.update { preferences in
108 | preferences.vercelToken = vercelToken
109 | preferences.teamId = teamId
110 | preferences.projectName = projectName
111 | preferences.gitBranches = gitBranches
112 | preferences.showProduction = showProduction
113 | preferences.showPreview = showPreview
114 | preferences.showReady = showReady
115 | preferences.showBuilding = showBuilding
116 | preferences.showError = showError
117 | preferences.showQueued = showQueued
118 | preferences.showCanceled = showCanceled
119 | preferences.limitByCount = Int(limitByCount)
120 | preferences.limitByHours = Int(limitByHours)
121 | preferences.refreshIntervalIdle = Int(refreshIntervalIdle)
122 | preferences.refreshIntervalBuilding = Int(refreshIntervalBuilding)
123 | }
124 | }
125 | }
126 |
127 | struct PreferencesView: View {
128 | @ObservedObject var viewModel: PreferencesViewModel
129 |
130 | var body: some View {
131 | ScrollView {
132 | VStack(alignment: .leading, spacing: 20) {
133 | // Authentication Section
134 | VStack(alignment: .leading, spacing: 12) {
135 | Text("Authentication")
136 | .font(.headline)
137 | .foregroundStyle(.primary)
138 |
139 | VStack(alignment: .leading, spacing: 8) {
140 | Text("Vercel API Token")
141 | .font(.subheadline)
142 | .foregroundStyle(.secondary)
143 | PasteableSecureField(text: $viewModel.vercelToken, placeholder: "Enter your token")
144 | .help("Required. Create a token at vercel.com/account/tokens with 'Read Deployments' permission.")
145 | }
146 |
147 | VStack(alignment: .leading, spacing: 8) {
148 | Text("Team ID (optional)")
149 | .font(.subheadline)
150 | .foregroundStyle(.secondary)
151 | TextField("", text: $viewModel.teamId, prompt: Text("Leave blank to fetch all teams"))
152 | .textFieldStyle(.roundedBorder)
153 | .help("Required if using a team-scoped token. Optional otherwise - leave blank to show deployments across all teams.")
154 | }
155 |
156 | VStack(alignment: .leading, spacing: 8) {
157 | Text("Project Name (optional)")
158 | .font(.subheadline)
159 | .foregroundStyle(.secondary)
160 | TextField("", text: $viewModel.projectName, prompt: Text("Filter by project"))
161 | .textFieldStyle(.roundedBorder)
162 | .help("Optional. Filter deployments to a specific project.")
163 | }
164 |
165 | Text("If you get a 403 error with a team-scoped token, make sure to provide the Team ID above.")
166 | .font(.caption)
167 | .foregroundStyle(.secondary)
168 | .padding(.top, 4)
169 | }
170 | .padding()
171 | .background(Color(nsColor: .controlBackgroundColor))
172 | .cornerRadius(8)
173 |
174 | // Filters Section
175 | VStack(alignment: .leading, spacing: 12) {
176 | Text("Filters")
177 | .font(.headline)
178 | .foregroundStyle(.primary)
179 |
180 | VStack(alignment: .leading, spacing: 8) {
181 | Text("Git Branches (optional)")
182 | .font(.subheadline)
183 | .foregroundStyle(.secondary)
184 | TextField("", text: $viewModel.gitBranches, prompt: Text("e.g. main, develop"))
185 | .textFieldStyle(.roundedBorder)
186 | .help("Comma-separated list of branches to filter (e.g. main, develop)")
187 | }
188 |
189 | VStack(alignment: .leading, spacing: 8) {
190 | Text("Deployment Types")
191 | .font(.subheadline)
192 | .foregroundStyle(.secondary)
193 | HStack(spacing: 16) {
194 | Toggle("Production", isOn: $viewModel.showProduction)
195 | Toggle("Preview", isOn: $viewModel.showPreview)
196 | }
197 | }
198 |
199 | VStack(alignment: .leading, spacing: 8) {
200 | Text("Deployment States")
201 | .font(.subheadline)
202 | .foregroundStyle(.secondary)
203 | VStack(alignment: .leading, spacing: 4) {
204 | HStack(spacing: 16) {
205 | Toggle("Ready", isOn: $viewModel.showReady)
206 | Toggle("Building", isOn: $viewModel.showBuilding)
207 | }
208 | HStack(spacing: 16) {
209 | Toggle("Error", isOn: $viewModel.showError)
210 | Toggle("Queued", isOn: $viewModel.showQueued)
211 | }
212 | Toggle("Canceled", isOn: $viewModel.showCanceled)
213 | }
214 | }
215 | }
216 | .padding()
217 | .background(Color(nsColor: .controlBackgroundColor))
218 | .cornerRadius(8)
219 |
220 | // Limits Section
221 | VStack(alignment: .leading, spacing: 12) {
222 | Text("Limits")
223 | .font(.headline)
224 | .foregroundStyle(.primary)
225 |
226 | VStack(alignment: .leading, spacing: 8) {
227 | Text("Maximum Deployments (optional)")
228 | .font(.subheadline)
229 | .foregroundStyle(.secondary)
230 | NumericTextField(text: $viewModel.limitByCount, placeholder: "e.g. 5")
231 | .help("Maximum number of deployments to display (leave blank to ignore)")
232 | }
233 |
234 | VStack(alignment: .leading, spacing: 8) {
235 | Text("Show Only Last X Hours (optional)")
236 | .font(.subheadline)
237 | .foregroundStyle(.secondary)
238 | NumericTextField(text: $viewModel.limitByHours, placeholder: "e.g. 24")
239 | .help("Only show deployments created within the last X hours (used when Maximum Deployments is empty)")
240 | }
241 |
242 | Text("Limit by count (max deployments) OR by time (recent hours). If both are set, count takes priority.")
243 | .font(.caption)
244 | .foregroundStyle(.secondary)
245 | .padding(.top, 4)
246 | }
247 | .padding()
248 | .background(Color(nsColor: .controlBackgroundColor))
249 | .cornerRadius(8)
250 |
251 | // Refresh Intervals Section
252 | VStack(alignment: .leading, spacing: 12) {
253 | Text("Refresh Intervals")
254 | .font(.headline)
255 | .foregroundStyle(.primary)
256 |
257 | VStack(alignment: .leading, spacing: 8) {
258 | Text("Idle Interval (seconds)")
259 | .font(.subheadline)
260 | .foregroundStyle(.secondary)
261 | NumericTextField(text: $viewModel.refreshIntervalIdle, placeholder: "Default: 15")
262 | .help("How often to check for new deployments when none are building (in seconds)")
263 | }
264 |
265 | VStack(alignment: .leading, spacing: 8) {
266 | Text("Building Interval (seconds)")
267 | .font(.subheadline)
268 | .foregroundStyle(.secondary)
269 | NumericTextField(text: $viewModel.refreshIntervalBuilding, placeholder: "Default: 2")
270 | .help("How often to check for updates when deployments are building or queued (in seconds)")
271 | }
272 |
273 | Text("Lower values provide faster updates but may use more API requests. Defaults: 15s idle, 2s building.")
274 | .font(.caption)
275 | .foregroundStyle(.secondary)
276 | .padding(.top, 4)
277 | }
278 | .padding()
279 | .background(Color(nsColor: .controlBackgroundColor))
280 | .cornerRadius(8)
281 |
282 | // Close button
283 | HStack {
284 | Spacer()
285 | Button("Close") {
286 | viewModel.saveAndClose()
287 | PreferencesWindowController.shared.close()
288 | }
289 | .buttonStyle(.borderedProminent)
290 | .controlSize(.large)
291 | .keyboardShortcut(.return)
292 | }
293 | .padding(.top, 8)
294 | }
295 | .padding()
296 | }
297 | .frame(minWidth: 500, minHeight: 680)
298 | .background(Color(nsColor: .windowBackgroundColor))
299 | }
300 | }
301 |
302 | private struct PasteableSecureField: NSViewRepresentable {
303 | @Binding var text: String
304 | var placeholder: String
305 |
306 | func makeCoordinator() -> Coordinator {
307 | Coordinator(parent: self)
308 | }
309 |
310 | func makeNSView(context: Context) -> NSSecureTextField {
311 | let field = PasteEnabledSecureTextField()
312 | field.placeholderString = placeholder
313 | field.delegate = context.coordinator
314 | field.isBezeled = true
315 | field.drawsBackground = true
316 | field.focusRingType = .default
317 | field.usesSingleLineMode = true
318 | field.isBordered = true
319 | return field
320 | }
321 |
322 | func updateNSView(_ nsView: NSSecureTextField, context: Context) {
323 | if nsView.stringValue != text {
324 | nsView.stringValue = text
325 | }
326 | nsView.placeholderString = placeholder
327 | }
328 |
329 | final class Coordinator: NSObject, NSTextFieldDelegate {
330 | private let parent: PasteableSecureField
331 |
332 | init(parent: PasteableSecureField) {
333 | self.parent = parent
334 | }
335 |
336 | func controlTextDidChange(_ obj: Notification) {
337 | guard let field = obj.object as? NSSecureTextField else { return }
338 | parent.text = field.stringValue
339 | }
340 |
341 | func controlTextDidEndEditing(_ obj: Notification) {
342 | guard let field = obj.object as? NSSecureTextField else { return }
343 | parent.text = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
344 | }
345 | }
346 | }
347 |
348 | private final class PasteEnabledSecureTextField: NSSecureTextField {
349 | override func performKeyEquivalent(with event: NSEvent) -> Bool {
350 | guard event.type == .keyDown else { return super.performKeyEquivalent(with: event) }
351 | if event.modifierFlags.contains(.command),
352 | let characters = event.charactersIgnoringModifiers?.lowercased() {
353 | switch characters {
354 | case "v":
355 | NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: self)
356 | return true
357 | default:
358 | break
359 | }
360 | }
361 | return super.performKeyEquivalent(with: event)
362 | }
363 | }
364 |
365 | private struct NumericTextField: View {
366 | @Binding var text: String
367 | let placeholder: String
368 |
369 | var body: some View {
370 | TextField("", text: $text, prompt: Text(placeholder))
371 | .textFieldStyle(.roundedBorder)
372 | .onChange(of: text) { newValue in
373 | // Only allow digits
374 | let filtered = newValue.filter { $0.isNumber }
375 | if filtered != newValue {
376 | text = filtered
377 | }
378 | }
379 | }
380 | }
381 |
--------------------------------------------------------------------------------
/Sources/vercel-deployment-menu-bar/StatusItemController.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 | import Foundation
3 |
4 | final class StatusItemController {
5 | private let statusItem: NSStatusItem
6 | private let menu = NSMenu()
7 | private let deploymentService = DeploymentService()
8 |
9 | private var refreshTimer: Timer?
10 | private var tickTimer: Timer?
11 | private var latestDeployments: [Deployment] = []
12 | private var lastError: Error?
13 | private var lastFetchDate: Date?
14 | private var missingToken: Bool = false
15 |
16 | private var preferencesObserver: NSObjectProtocol?
17 |
18 | init() {
19 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
20 | statusItem.menu = menu
21 | statusItem.button?.imagePosition = .imageLeft
22 | statusItem.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium)
23 | statusItem.button?.title = "VRC"
24 | }
25 |
26 | func start() {
27 | preferencesObserver = NotificationCenter.default.addObserver(
28 | forName: PreferencesStore.didChangeNotification,
29 | object: nil,
30 | queue: .main
31 | ) { [weak self] _ in
32 | self?.refreshDeployments(userInitiated: true)
33 | }
34 |
35 | buildMenu()
36 | startTickTimer()
37 | refreshDeployments()
38 | }
39 |
40 | func stop() {
41 | preferencesObserver.flatMap(NotificationCenter.default.removeObserver)
42 | preferencesObserver = nil
43 | refreshTimer?.invalidate()
44 | refreshTimer = nil
45 | tickTimer?.invalidate()
46 | tickTimer = nil
47 | }
48 |
49 | @objc private func refreshFromMenu(_ sender: Any?) {
50 | refreshDeployments(userInitiated: true)
51 | }
52 |
53 | @objc private func openDashboard(_ sender: Any?) {
54 | let preferences = PreferencesStore.shared.current
55 | var urlString = "https://vercel.com/deployments"
56 | if !preferences.teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
57 | urlString = "https://vercel.com/\(preferences.teamId)/deployments"
58 | }
59 | if let url = URL(string: urlString) {
60 | NSWorkspace.shared.open(url)
61 | }
62 | }
63 |
64 | @objc private func openDeployment(_ sender: NSMenuItem) {
65 | guard let url = sender.representedObject as? URL else { return }
66 | NSWorkspace.shared.open(url)
67 | }
68 |
69 | @objc private func openPreferences(_ sender: Any?) {
70 | PreferencesWindowController.shared.show()
71 | }
72 |
73 | @objc private func quitApp(_ sender: Any?) {
74 | NSApp.terminate(nil)
75 | }
76 |
77 | private func startTickTimer() {
78 | tickTimer?.invalidate()
79 | tickTimer = Timer.scheduledTimer(
80 | timeInterval: 1.0,
81 | target: self,
82 | selector: #selector(tick),
83 | userInfo: nil,
84 | repeats: true
85 | )
86 | RunLoop.main.add(tickTimer!, forMode: .common)
87 | }
88 |
89 | @objc private func tick() {
90 | updateStatusButton()
91 | }
92 |
93 | private func scheduleRefreshTimer(hasBuilding: Bool) {
94 | refreshTimer?.invalidate()
95 | let preferences = PreferencesStore.shared.current
96 | let idleInterval = TimeInterval(preferences.refreshIntervalIdle ?? 15)
97 | let buildingInterval = TimeInterval(preferences.refreshIntervalBuilding ?? 2)
98 | let interval: TimeInterval = hasBuilding ? buildingInterval : idleInterval
99 | refreshTimer = Timer.scheduledTimer(
100 | timeInterval: interval,
101 | target: self,
102 | selector: #selector(triggerRefreshTimer),
103 | userInfo: nil,
104 | repeats: true
105 | )
106 | RunLoop.main.add(refreshTimer!, forMode: .common)
107 | }
108 |
109 | @objc private func triggerRefreshTimer() {
110 | refreshDeployments()
111 | }
112 |
113 | private func refreshDeployments(userInitiated: Bool = false) {
114 | Task {
115 | do {
116 | let preferences = PreferencesStore.shared.current
117 | guard preferences.hasToken else {
118 | await MainActor.run {
119 | self.latestDeployments = []
120 | self.lastError = nil
121 | self.missingToken = true
122 | self.buildMenu()
123 | self.updateStatusButton()
124 | }
125 | return
126 | }
127 |
128 | let deployments = try await deploymentService.fetchAllDeployments(preferences: preferences)
129 | let filtered = filterDeployments(deployments, with: preferences)
130 |
131 | await MainActor.run {
132 | self.latestDeployments = filtered
133 | self.lastError = nil
134 | self.missingToken = false
135 | self.lastFetchDate = Date()
136 | let hasBuilding = filtered.contains { $0.state == .building || $0.state == .queued }
137 | self.scheduleRefreshTimer(hasBuilding: hasBuilding)
138 | self.buildMenu()
139 | self.updateStatusButton()
140 | }
141 | } catch {
142 | await MainActor.run {
143 | self.latestDeployments = []
144 | self.lastError = error
145 | self.missingToken = false
146 | self.buildMenu()
147 | self.updateStatusButton()
148 | }
149 | }
150 | }
151 | }
152 |
153 | private func filterDeployments(_ deployments: [Deployment], with preferences: Preferences) -> [Deployment] {
154 | var filtered = deployments.filter { deployment in
155 | switch deployment.state {
156 | case .ready:
157 | guard preferences.showReady else { return false }
158 | case .building:
159 | guard preferences.showBuilding else { return false }
160 | case .error:
161 | guard preferences.showError else { return false }
162 | case .queued:
163 | guard preferences.showQueued else { return false }
164 | case .canceled:
165 | guard preferences.showCanceled else { return false }
166 | case .unknown:
167 | break
168 | }
169 |
170 | if let target = deployment.target?.lowercased() {
171 | if target == "production", !preferences.showProduction {
172 | return false
173 | }
174 | if target == "preview", !preferences.showPreview {
175 | return false
176 | }
177 | }
178 |
179 | if !preferences.branchList.isEmpty {
180 | let branch = (deployment.gitSource?.ref ?? deployment.meta?.githubCommitRef ?? "").lowercased()
181 | if branch.isEmpty || !preferences.branchList.contains(branch) {
182 | return false
183 | }
184 | }
185 |
186 | return true
187 | }
188 |
189 | if let limit = preferences.limitByCount, limit > 0 {
190 | filtered = Array(filtered.prefix(limit))
191 | } else if let hours = preferences.limitByHours, hours > 0 {
192 | let cutoff = Date().addingTimeInterval(TimeInterval(-hours * 3600))
193 | filtered = filtered.filter { $0.createdDate >= cutoff }
194 | }
195 |
196 | return filtered.sorted { $0.created > $1.created }
197 | }
198 |
199 | private func updateStatusButton() {
200 | guard let button = statusItem.button else { return }
201 |
202 | if missingToken {
203 | button.title = "No Token"
204 | button.image = NSImage(systemSymbolName: "key.slash", accessibilityDescription: nil)
205 | return
206 | }
207 |
208 | guard lastError == nil else {
209 | button.title = "Error"
210 | button.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: nil)
211 | return
212 | }
213 |
214 | guard let latest = latestDeployments.first else {
215 | button.title = "VRC"
216 | button.image = NSImage(systemSymbolName: "circle", accessibilityDescription: nil)
217 | return
218 | }
219 |
220 | button.image = icon(for: latest.state)
221 | button.title = formattedStatusTitle(for: latest)
222 | }
223 |
224 | private func formattedStatusTitle(for deployment: Deployment) -> String {
225 | let label = String(deployment.name.prefix(3)).uppercased()
226 | switch deployment.state {
227 | case .building:
228 | let elapsed = Date().timeIntervalSince(deployment.buildingAtDate)
229 | return "\(label) \(formatDuration(elapsed))"
230 | case .queued:
231 | let elapsed = Date().timeIntervalSince(deployment.createdDate)
232 | return "\(label) \(formatDuration(elapsed))"
233 | case .ready:
234 | if let readyDate = deployment.readyDate {
235 | let elapsed = readyDate.timeIntervalSince(deployment.buildingAtDate)
236 | return "\(label) \(formatDuration(max(elapsed, 0)))"
237 | }
238 | return label
239 | case .error:
240 | if let readyDate = deployment.readyDate {
241 | let elapsed = readyDate.timeIntervalSince(deployment.buildingAtDate)
242 | let formatted = formatDuration(max(elapsed, 0))
243 | return "\(label) \(formatted)"
244 | }
245 | return label
246 | case .canceled, .unknown:
247 | return label
248 | }
249 | }
250 |
251 | private func formatDuration(_ seconds: TimeInterval) -> String {
252 | let sec = Int(seconds)
253 | let hours = sec / 3600
254 | let minutes = (sec % 3600) / 60
255 | let remaining = sec % 60
256 |
257 | if hours > 0 {
258 | return "\(hours)h \(minutes)m \(remaining)s"
259 | } else if minutes > 0 {
260 | return "\(minutes)m \(remaining)s"
261 | } else {
262 | return "\(remaining)s"
263 | }
264 | }
265 |
266 | private func formatTime(_ date: Date) -> String {
267 | let formatter = DateFormatter()
268 | formatter.timeStyle = .short
269 | formatter.dateStyle = .none
270 | return formatter.string(from: date)
271 | }
272 |
273 | private func icon(for state: Deployment.State) -> NSImage? {
274 | let symbolName: String
275 | let color: NSColor
276 |
277 | switch state {
278 | case .ready:
279 | symbolName = "checkmark.circle.fill"
280 | color = .systemGreen
281 | case .error:
282 | symbolName = "xmark.circle.fill"
283 | color = .systemRed
284 | case .building:
285 | symbolName = "hourglass"
286 | color = .systemYellow
287 | case .queued:
288 | symbolName = "clock.fill"
289 | color = .systemOrange
290 | case .canceled:
291 | symbolName = "minus.circle.fill"
292 | color = .systemGray
293 | case .unknown:
294 | symbolName = "questionmark.circle"
295 | color = .systemGray
296 | }
297 |
298 | guard let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) else {
299 | return nil
300 | }
301 |
302 | // Create a colored version of the image
303 | let coloredImage = NSImage(size: image.size)
304 | coloredImage.lockFocus()
305 | color.set()
306 |
307 | let rect = NSRect(origin: .zero, size: image.size)
308 | image.draw(in: rect, from: rect, operation: .sourceOver, fraction: 1.0)
309 |
310 | // Apply the color using a compositing operation
311 | rect.fill(using: .sourceAtop)
312 |
313 | coloredImage.unlockFocus()
314 | coloredImage.isTemplate = false
315 |
316 | return coloredImage
317 | }
318 |
319 | private func buildMenu() {
320 | menu.removeAllItems()
321 |
322 | if missingToken {
323 | menu.addItem(NSMenuItem(title: "Add your Vercel token in Preferences", action: nil, keyEquivalent: ""))
324 | menu.addItem(.separator())
325 | } else if let error = lastError {
326 | let errorItem = NSMenuItem(
327 | title: "Error: \(error.localizedDescription)",
328 | action: nil,
329 | keyEquivalent: ""
330 | )
331 | menu.addItem(errorItem)
332 | menu.addItem(.separator())
333 | }
334 |
335 | if latestDeployments.isEmpty && lastError == nil && !missingToken {
336 | menu.addItem(NSMenuItem(title: "No deployments found", action: nil, keyEquivalent: ""))
337 | } else if !missingToken {
338 | for deployment in latestDeployments {
339 | let title = menuTitle(for: deployment)
340 | let item = NSMenuItem(
341 | title: title,
342 | action: #selector(openDeployment(_:)),
343 | keyEquivalent: ""
344 | )
345 | item.target = self
346 |
347 | // Build Vercel dashboard URL for this deployment
348 | let preferences = PreferencesStore.shared.current
349 | var dashboardURL: String
350 | if !preferences.teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
351 | dashboardURL = "https://vercel.com/\(preferences.teamId)/\(deployment.name)/\(deployment.uid)"
352 | } else {
353 | // For personal accounts, try using the deployment URL to construct it
354 | dashboardURL = "https://vercel.com/\(deployment.name)/\(deployment.uid)"
355 | }
356 |
357 | if let url = URL(string: dashboardURL) {
358 | item.representedObject = url
359 | }
360 | item.toolTip = deployment.meta?.githubCommitMessage ?? ""
361 | item.image = icon(for: deployment.state)
362 | menu.addItem(item)
363 | }
364 | }
365 |
366 | if let lastFetchDate {
367 | menu.addItem(.separator())
368 | let formatter = DateFormatter()
369 | formatter.dateStyle = .none
370 | formatter.timeStyle = .short
371 | let updatedTitle = "Last updated: \(formatter.string(from: lastFetchDate))"
372 | let updatedItem = NSMenuItem(title: updatedTitle, action: nil, keyEquivalent: "")
373 | updatedItem.isEnabled = false
374 | menu.addItem(updatedItem)
375 | }
376 |
377 | menu.addItem(.separator())
378 |
379 | let refreshItem = NSMenuItem(title: "Refresh Now", action: #selector(refreshFromMenu(_:)), keyEquivalent: "r")
380 | refreshItem.target = self
381 | menu.addItem(refreshItem)
382 |
383 | let dashboardItem = NSMenuItem(title: "Open Vercel Dashboard", action: #selector(openDashboard(_:)), keyEquivalent: "")
384 | dashboardItem.target = self
385 | menu.addItem(dashboardItem)
386 |
387 | let preferencesItem = NSMenuItem(title: "Preferences…", action: #selector(openPreferences(_:)), keyEquivalent: ",")
388 | preferencesItem.target = self
389 | menu.addItem(preferencesItem)
390 |
391 | menu.addItem(.separator())
392 |
393 | let quitItem = NSMenuItem(title: "Quit Vercel Deployment Menu Bar", action: #selector(quitApp(_:)), keyEquivalent: "q")
394 | quitItem.target = self
395 | menu.addItem(quitItem)
396 | }
397 |
398 | private func menuTitle(for deployment: Deployment) -> String {
399 | var components: [String] = []
400 |
401 | let gitBranch = deployment.gitSource?.ref ?? deployment.meta?.githubCommitRef ?? ""
402 | if gitBranch.isEmpty {
403 | components.append(deployment.name)
404 | } else {
405 | components.append("\(deployment.name) (\(gitBranch))")
406 | }
407 |
408 | var statusParts: [String] = []
409 | if let target = deployment.target {
410 | statusParts.append(target.capitalized)
411 | }
412 |
413 | switch deployment.state {
414 | case .building:
415 | statusParts.append("Building \(formatDuration(Date().timeIntervalSince(deployment.buildingAtDate)))")
416 | case .queued:
417 | statusParts.append("Queued \(formatDuration(Date().timeIntervalSince(deployment.createdDate)))")
418 | case .ready:
419 | if let readyDate = deployment.readyDate {
420 | let duration = readyDate.timeIntervalSince(deployment.buildingAtDate)
421 | statusParts.append("Ready \(formatDuration(max(duration, 0)))")
422 | } else {
423 | statusParts.append("Ready")
424 | }
425 | case .error:
426 | statusParts.append("Error")
427 | case .canceled:
428 | statusParts.append("Canceled")
429 | case .unknown:
430 | statusParts.append("Unknown")
431 | }
432 |
433 | let timeString = formatTime(deployment.buildingAtDate)
434 | statusParts.append(timeString)
435 |
436 | if !statusParts.isEmpty {
437 | components.append(statusParts.joined(separator: " • "))
438 | }
439 |
440 | return components.joined(separator: " — ")
441 | }
442 | }
443 |
--------------------------------------------------------------------------------