diff --git a/Sources/MacRunner/MacRunnerApp.swift b/Sources/MacRunner/MacRunnerApp.swift index 7faa39e..4334dd9 100644 --- a/Sources/MacRunner/MacRunnerApp.swift +++ b/Sources/MacRunner/MacRunnerApp.swift @@ -26,6 +26,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + JobNotificationService.shared.configure() statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) diff --git a/Sources/Models/Runner.swift b/Sources/Models/Runner.swift index 5adad05..3c81f08 100644 --- a/Sources/Models/Runner.swift +++ b/Sources/Models/Runner.swift @@ -184,6 +184,7 @@ struct AppSettings: Codable, Sendable { var quietHours: QuietHours? var isolationMode: IsolationMode var tools: ToolProvisioningSettings + var notificationsEnabled: Bool var autoCheckForUpdates: Bool var autoRestartEnabled: Bool var autoRestartMaxRetries: Int @@ -195,6 +196,7 @@ struct AppSettings: Codable, Sendable { quietHours: nil, isolationMode: .none, tools: .default, + notificationsEnabled: true, autoCheckForUpdates: true, autoRestartEnabled: true, autoRestartMaxRetries: 5, @@ -207,6 +209,7 @@ struct AppSettings: Codable, Sendable { quietHours: QuietHours? = nil, isolationMode: IsolationMode = .none, tools: ToolProvisioningSettings = .default, + notificationsEnabled: Bool = true, autoCheckForUpdates: Bool = true, autoRestartEnabled: Bool = true, autoRestartMaxRetries: Int = 5, @@ -217,6 +220,7 @@ struct AppSettings: Codable, Sendable { self.quietHours = quietHours self.isolationMode = isolationMode self.tools = tools + self.notificationsEnabled = notificationsEnabled self.autoCheckForUpdates = autoCheckForUpdates self.autoRestartEnabled = autoRestartEnabled self.autoRestartMaxRetries = max(1, autoRestartMaxRetries) @@ -230,6 +234,9 @@ struct AppSettings: Codable, Sendable { quietHours = try container.decodeIfPresent(QuietHours.self, forKey: .quietHours) isolationMode = try container.decodeIfPresent(IsolationMode.self, forKey: .isolationMode) ?? .none tools = try container.decodeIfPresent(ToolProvisioningSettings.self, forKey: .tools) ?? .default + notificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .notificationsEnabled) ?? true + tools = try container.decodeIfPresent(ToolProvisioningSettings.self, forKey: .tools) ?? .default + notificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .notificationsEnabled) ?? true autoCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .autoCheckForUpdates) ?? true autoRestartEnabled = try container.decodeIfPresent(Bool.self, forKey: .autoRestartEnabled) ?? true autoRestartMaxRetries = max(1, try container.decodeIfPresent(Int.self, forKey: .autoRestartMaxRetries) ?? 5) diff --git a/Sources/Services/ContainerIsolationService.swift b/Sources/Services/ContainerIsolationService.swift index 2bccc4e..85865ed 100644 --- a/Sources/Services/ContainerIsolationService.swift +++ b/Sources/Services/ContainerIsolationService.swift @@ -73,7 +73,7 @@ class ContainerIsolationService { let kernel = Kernel(path: kernelPath, platform: .linuxArm) // Create network configuration (vmnet shared mode) - let network = try ContainerManager.VmnetNetwork() + let network = try VmnetNetwork() // Initialize container manager with kernel and network // vminit will be fetched automatically from registry on first use diff --git a/Sources/Services/GHCLIService.swift b/Sources/Services/GHCLIService.swift index d814667..85ecbb5 100644 --- a/Sources/Services/GHCLIService.swift +++ b/Sources/Services/GHCLIService.swift @@ -212,6 +212,34 @@ final class GHCLIService: Sendable { return Set(entries) } + func currentJob(for repo: String, runnerName: String) async throws -> WorkflowJobSummary? { + let runs = try await listWorkflowRuns(for: repo, status: "in_progress") + + for run in runs { + let jobs = try await listJobs(for: repo, runID: run.id, run: run) + if let job = jobs.first(where: { + $0.runnerName == runnerName && $0.status != "completed" + }) { + return job + } + } + + return nil + } + + func completedJob(for repo: String, runnerName: String, runID: Int) async throws -> WorkflowJobSummary? { + let runs = try await listWorkflowRuns(for: repo, status: "completed") + + guard let run = runs.first(where: { $0.id == runID }) else { + return nil + } + + let jobs = try await listJobs(for: repo, runID: run.id, run: run) + return jobs.first(where: { + $0.runnerName == runnerName && $0.status == "completed" + }) + } + // MARK: - Runners func getRegistrationToken(for repo: String) async throws -> String { @@ -272,6 +300,73 @@ final class GHCLIService: Sendable { throw GHError.apiFailed("Failed to delete runner: \(result.stderr)") } } + + private func listWorkflowRuns(for repo: String, status: String) async throws -> [WorkflowRunSummary] { + let result = try await runGH([ + "api", "repos/\(repo)/actions/runs?status=\(status)&per_page=10", + "--jq", ".workflow_runs" + ]) + guard result.exitCode == 0 else { + throw GHError.apiFailed("Failed to list workflow runs: \(result.stderr)") + } + + struct APIWorkflowRun: Decodable { + let id: Int + let name: String? + let htmlURL: URL + + enum CodingKeys: String, CodingKey { + case id + case name + case htmlURL = "html_url" + } + } + + let data = Data(result.stdout.utf8) + let runs = try JSONDecoder().decode([APIWorkflowRun].self, from: data) + return runs.map { + WorkflowRunSummary(id: $0.id, name: $0.name ?? "GitHub Actions", htmlURL: $0.htmlURL) + } + } + + private func listJobs(for repo: String, runID: Int, run: WorkflowRunSummary) async throws -> [WorkflowJobSummary] { + let result = try await runGH([ + "api", "repos/\(repo)/actions/runs/\(runID)/jobs", + "--jq", ".jobs" + ]) + guard result.exitCode == 0 else { + throw GHError.apiFailed("Failed to list workflow jobs: \(result.stderr)") + } + + struct APIJob: Decodable { + let id: Int + let name: String + let status: String + let conclusion: String? + let runnerName: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case status + case conclusion + case runnerName = "runner_name" + } + } + + let data = Data(result.stdout.utf8) + let jobs = try JSONDecoder().decode([APIJob].self, from: data) + return jobs.map { + WorkflowJobSummary( + id: $0.id, + name: $0.name, + status: $0.status, + conclusion: $0.conclusion, + runnerName: $0.runnerName, + run: run + ) + } + } } enum GHError: LocalizedError { diff --git a/Sources/Services/JobNotificationService.swift b/Sources/Services/JobNotificationService.swift new file mode 100644 index 0000000..7048f61 --- /dev/null +++ b/Sources/Services/JobNotificationService.swift @@ -0,0 +1,121 @@ +import AppKit +import Foundation +import UserNotifications + +struct WorkflowRunSummary: Sendable, Equatable { + let id: Int + let name: String + let htmlURL: URL +} + +struct WorkflowJobSummary: Sendable, Equatable { + let id: Int + let name: String + let status: String + let conclusion: String? + let runnerName: String? + let run: WorkflowRunSummary +} + +struct JobNotificationPayload: Equatable { + let title: String + let body: String + let runURL: URL +} + +enum JobNotificationEvent { + case started + case completed +} + +enum JobNotificationPayloadFactory { + static func make(event: JobNotificationEvent, runner: Runner, job: WorkflowJobSummary) -> JobNotificationPayload { + let workflowName = job.run.name.isEmpty ? job.name : job.run.name + + switch event { + case .started: + return JobNotificationPayload( + title: "Job started on \(runner.name)", + body: "\(runner.repo) - \(workflowName)", + runURL: job.run.htmlURL + ) + case .completed: + let conclusion = job.conclusion ?? "completed" + let title = conclusion == "success" + ? "Job completed on \(runner.name)" + : "Job failed on \(runner.name)" + return JobNotificationPayload( + title: title, + body: "\(runner.repo) - \(workflowName) (\(conclusion))", + runURL: job.run.htmlURL + ) + } + } +} + +@MainActor +final class JobNotificationService: NSObject, @preconcurrency UNUserNotificationCenterDelegate { + static let shared = JobNotificationService() + + private let notificationCenter = UNUserNotificationCenter.current() + private var authorizationRequested = false + + func configure() { + notificationCenter.delegate = self + requestAuthorizationIfNeededInternal() + } + + func notify(event: JobNotificationEvent, runner: Runner, job: WorkflowJobSummary) async { + requestAuthorizationIfNeeded() + + let payload = JobNotificationPayloadFactory.make(event: event, runner: runner, job: job) + let content = UNMutableNotificationContent() + content.title = payload.title + content.body = payload.body + content.sound = .default + content.userInfo = ["runURL": payload.runURL.absoluteString] + + let request = UNNotificationRequest( + identifier: "job-\(runner.id.uuidString)-\(job.id)-\(eventIdentifier(for: event))", + content: content, + trigger: nil + ) + + notificationCenter.add(request, withCompletionHandler: nil) + } + + private func requestAuthorizationIfNeeded() { + requestAuthorizationIfNeededInternal() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + defer { completionHandler() } + + guard let rawURL = response.notification.request.content.userInfo["runURL"] as? String, + let url = URL(string: rawURL) else { + return + } + + NSWorkspace.shared.open(url) + } + + private func requestAuthorizationIfNeededInternal() { + guard !authorizationRequested else { return } + authorizationRequested = true + + notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in } + } + + private func eventIdentifier(for event: JobNotificationEvent) -> String { + switch event { + case .started: + return "started" + case .completed: + return "completed" + } + } +} diff --git a/Sources/Services/RunnerManager.swift b/Sources/Services/RunnerManager.swift index ec4a160..3a6325e 100644 --- a/Sources/Services/RunnerManager.swift +++ b/Sources/Services/RunnerManager.swift @@ -29,6 +29,7 @@ class RunnerManager: ObservableObject { private let ghService = GHCLIService.shared private let isolationService = UserIsolationService.shared private let toolProvisioningService = ToolProvisioningService() + private let jobNotificationService = JobNotificationService.shared private let processManager = ProcessManager() private let pidManager = PIDFileManager() private let updateChecker = UpdateChecker() @@ -51,6 +52,7 @@ class RunnerManager: ObservableObject { private(set) var currentSettings: AppSettings = .default private var statusPollingTask: Task? private var runnersToAutoRestart: Set = [] + private var activeWorkflowJobs: [UUID: WorkflowJobSummary] = [:] /// Names reserved by in-flight addRunner calls to prevent duplicate naming race conditions. private var pendingRunnerNames: Set = [] private var manualStopRequests: Set = [] @@ -87,6 +89,12 @@ class RunnerManager: ObservableObject { } } + for runner in runners where runner.busy { + Task { [weak self] in + await self?.restoreActiveJobState(for: runner) + } + } + reconcileRunnerStates() syncLoginItem() startStatusPolling() @@ -664,6 +672,7 @@ class RunnerManager: ObservableObject { // Use per-runner isolation mode if specified, otherwise use global setting let isolation = runner.effectiveIsolationMode(global: currentSettings.isolationMode) manualStopRequests.insert(id) + activeWorkflowJobs.removeValue(forKey: id) // Check if this is a container-based runner do { @@ -798,8 +807,15 @@ class RunnerManager: ObservableObject { if let index = runners.firstIndex(where: { $0.id == runner.id }), let remoteRunner = remoteRunners.first(where: { $0.name == runner.name }) { if runners[index].busy != remoteRunner.busy { + let becameBusy = remoteRunner.busy runners[index].busy = remoteRunner.busy changed = true + + if becameBusy { + await handleJobStarted(for: runners[index]) + } else { + await handleJobCompleted(for: runners[index]) + } } } } @@ -1124,6 +1140,42 @@ class RunnerManager: ObservableObject { gitHubAuthIssue = nil } + private func restoreActiveJobState(for runner: Runner) async { + guard activeWorkflowJobs[runner.id] == nil else { return } + activeWorkflowJobs[runner.id] = try? await ghService.currentJob(for: runner.repo, runnerName: runner.name) + } + + private func handleJobStarted(for runner: Runner) async { + guard activeWorkflowJobs[runner.id] == nil else { return } + guard let job = try? await ghService.currentJob(for: runner.repo, runnerName: runner.name) else { + return + } + + activeWorkflowJobs[runner.id] = job + if currentSettings.notificationsEnabled { + await jobNotificationService.notify(event: .started, runner: runner, job: job) + } + } + + private func handleJobCompleted(for runner: Runner) async { + guard let activeJob = activeWorkflowJobs[runner.id] else { return } + defer { activeWorkflowJobs.removeValue(forKey: runner.id) } + + let completedJob = try? await ghService.completedJob( + for: runner.repo, + runnerName: runner.name, + runID: activeJob.run.id + ) + + if currentSettings.notificationsEnabled { + await jobNotificationService.notify( + event: .completed, + runner: runner, + job: completedJob ?? activeJob + ) + } + } + private func cancelScheduledRestarts(clearHistory: Bool) { for (id, task) in scheduledRestarts { task.cancel() diff --git a/Sources/Views/MenuBarView.swift b/Sources/Views/MenuBarView.swift index 78ce6a2..8f3f72b 100644 --- a/Sources/Views/MenuBarView.swift +++ b/Sources/Views/MenuBarView.swift @@ -453,6 +453,19 @@ struct SettingsView: View { .font(.caption) .foregroundColor(.secondary) + Toggle("Job Notifications", isOn: Binding( + get: { runnerManager.currentSettings.notificationsEnabled }, + set: { newValue in + var settings = runnerManager.currentSettings + settings.notificationsEnabled = newValue + runnerManager.updateSettings(settings) + } + )) + + Text("Show native macOS notifications when a runner starts a job and when that job completes. Clicking a notification opens the GitHub Actions run.") + .font(.caption) + .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 6) { Text("Default Open Files Limit") .font(.subheadline) diff --git a/Tests/MacRunnerTests/MacRunnerTests.swift b/Tests/MacRunnerTests/MacRunnerTests.swift index 0d41e9f..8999edc 100644 --- a/Tests/MacRunnerTests/MacRunnerTests.swift +++ b/Tests/MacRunnerTests/MacRunnerTests.swift @@ -218,6 +218,7 @@ final class MacRunnerTests: XCTestCase { let settings = try JSONDecoder().decode(AppSettings.self, from: data) XCTAssertEqual(settings.tools, .default) + XCTAssertTrue(settings.notificationsEnabled) } func testToolProvisioningSettingsNormalizesExtraPackages() { @@ -271,4 +272,50 @@ final class MacRunnerTests: XCTestCase { XCTAssertEqual(state.statusMessage, "gh CLI not found or not authenticated") XCTAssertEqual(state.recoveryMessage, "GitHub authentication expired or is invalid. Run: gh auth login") } + + func testJobNotificationPayloadFactoryBuildsStartedPayload() { + let runner = Runner(name: "runner-1", repo: "omniaura/mac-runner") + let run = WorkflowRunSummary( + id: 42, + name: "CI", + htmlURL: URL(string: "https://github.com/omniaura/mac-runner/actions/runs/42")! + ) + let job = WorkflowJobSummary( + id: 7, + name: "build", + status: "in_progress", + conclusion: nil, + runnerName: "runner-1", + run: run + ) + + let payload = JobNotificationPayloadFactory.make(event: .started, runner: runner, job: job) + + XCTAssertEqual(payload.title, "Job started on runner-1") + XCTAssertEqual(payload.body, "omniaura/mac-runner - CI") + XCTAssertEqual(payload.runURL, run.htmlURL) + } + + func testJobNotificationPayloadFactoryBuildsFailurePayload() { + let runner = Runner(name: "runner-1", repo: "omniaura/mac-runner") + let run = WorkflowRunSummary( + id: 42, + name: "Nightly", + htmlURL: URL(string: "https://github.com/omniaura/mac-runner/actions/runs/42")! + ) + let job = WorkflowJobSummary( + id: 8, + name: "test", + status: "completed", + conclusion: "failure", + runnerName: "runner-1", + run: run + ) + + let payload = JobNotificationPayloadFactory.make(event: .completed, runner: runner, job: job) + + XCTAssertEqual(payload.title, "Job failed on runner-1") + XCTAssertEqual(payload.body, "omniaura/mac-runner - Nightly (failure)") + XCTAssertEqual(payload.runURL, run.htmlURL) + } }