A macOS utility to track home-manager JJ repo status
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}