├── 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 | ![Vercel deployment status in macOS menu bar showing build/ready/error states](./vercel-menu-bar-deployment-status-macos.png) 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 |
31 |
32 | 35 |
36 |
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 | Vercel deployment status in macOS menu bar showing build, ready, and error states 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 |
  1. Go to your Vercel Dashboard
  2. 107 |
  3. Select your team from the dropdown
  4. 108 |
  5. Look at the URL: https://vercel.com/[TEAM_ID]/~
  6. 109 |
  7. The [TEAM_ID] is what you need (e.g., if the URL shows https://vercel.com/acme-corp/~, your Team ID is acme-corp)
  8. 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 | --------------------------------------------------------------------------------