A macOS utility to track home-manager JJ repo status
at main 171 lines 4.4 kB view raw
1import AppKit 2import HMStatus 3import SwiftUI 4 5@main 6struct HomeManagerStatusApp: App { 7 @StateObject private var checker: AppStatusChecker 8 9 init() { 10 // Hide from Dock this is a menu bar-only app. 11 // We set this programmatically because SPM executables don't have an Info.plist bundle. 12 NSApplication.shared.setActivationPolicy(.accessory) 13 14 let home = FileManager.default.homeDirectoryForCurrentUser.path 15 let repoPath = "\(home)/.config/home-manager" 16 let checkerInstance = AppStatusChecker(repoPath: repoPath) 17 _checker = StateObject(wrappedValue: checkerInstance) 18 19 // Start polling after a small delay to allow the app to finish launching. 20 Task { @MainActor in 21 checkerInstance.startPolling() 22 } 23 } 24 25 var body: some Scene { 26 MenuBarExtra { 27 MenuContent(checker: checker) 28 } label: { 29 MenuBarLabel(status: checker.status) 30 } 31 } 32} 33 34// MARK: - Menu Bar Label 35 36struct MenuBarLabel: View { 37 let status: RepoStatus 38 39 var body: some View { 40 Text(status.menuBarLabel) 41 .monospacedDigit() 42 } 43} 44 45// MARK: - Menu Content 46 47struct MenuContent: View { 48 @ObservedObject var checker: AppStatusChecker 49 @State private var ghosttyLaunchErrorMessage: String? 50 51 private let ghosttyLauncher = GhosttyLauncher() 52 53 private var formattedTime: String { 54 checker.status.checkedAt.formatted(date: .omitted, time: .standard) 55 } 56 57 var body: some View { 58 Group { 59 // Status section 60 Section { 61 ForEach( 62 checker.status.statusDescription.components(separatedBy: "\n"), id: \.self 63 ) { line in 64 Text(line) 65 } 66 67 Text("Last checked: \(formattedTime)") 68 .foregroundStyle(.secondary) 69 } 70 71 Divider() 72 73 // Actions 74 Section { 75 Button("Refresh Now") { 76 Task { await checker.refresh() } 77 } 78 .keyboardShortcut("r") 79 80 Button("Open in Ghostty") { 81 openInGhostty(path: checker.repoPath) 82 } 83 .keyboardShortcut("t") 84 } 85 86 Divider() 87 88 Button("Quit") { 89 NSApplication.shared.terminate(nil) 90 } 91 .keyboardShortcut("q") 92 } 93 .alert( 94 "Unable to Open Ghostty", 95 isPresented: Binding( 96 get: { ghosttyLaunchErrorMessage != nil }, 97 set: { isPresented in 98 if !isPresented { 99 ghosttyLaunchErrorMessage = nil 100 } 101 } 102 ) 103 ) { 104 Button("OK", role: .cancel) {} 105 } message: { 106 Text(ghosttyLaunchErrorMessage ?? "Unknown error") 107 } 108 } 109 110 private func openInGhostty(path: String) { 111 do { 112 try ghosttyLauncher.openNewWindow(path: path) 113 } catch { 114 ghosttyLaunchErrorMessage = error.localizedDescription 115 } 116 } 117} 118 119struct GhosttyLauncher { 120 enum LaunchError: LocalizedError { 121 case openCommandExecutionFailed(String) 122 case openCommandFailed(String) 123 124 var errorDescription: String? { 125 switch self { 126 case .openCommandExecutionFailed(let details): 127 return "Could not run open for Ghostty. \(details)" 128 case .openCommandFailed(let details): 129 if details.isEmpty { 130 return "Ghostty could not be opened. Make sure Ghostty is installed." 131 } 132 return "Ghostty could not be opened. \(details)" 133 } 134 } 135 } 136 137 func makeOpenArguments(path: String) -> [String] { 138 let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" 139 let shellCommand = "cd \(shellQuoted(path)) && exec \(shellQuoted(shell))" 140 return ["-n", "-a", "Ghostty", "--args", "-e", "/bin/sh", "-lc", shellCommand] 141 } 142 143 func openNewWindow(path: String) throws { 144 let process = Process() 145 process.executableURL = URL(fileURLWithPath: "/usr/bin/open") 146 process.arguments = makeOpenArguments(path: path) 147 148 let stderr = Pipe() 149 process.standardError = stderr 150 151 do { 152 try process.run() 153 } catch { 154 throw LaunchError.openCommandExecutionFailed(error.localizedDescription) 155 } 156 157 process.waitUntilExit() 158 159 guard process.terminationStatus == 0 else { 160 let data = stderr.fileHandleForReading.readDataToEndOfFile() 161 let output = 162 String(data: data, encoding: .utf8)? 163 .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 164 throw LaunchError.openCommandFailed(output) 165 } 166 } 167 168 private func shellQuoted(_ value: String) -> String { 169 "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" 170 } 171}