diff --git a/platforms/macos/Sources/AppState+AutoKill.swift b/platforms/macos/Sources/AppState+AutoKill.swift new file mode 100644 index 0000000..afa54e4 --- /dev/null +++ b/platforms/macos/Sources/AppState+AutoKill.swift @@ -0,0 +1,60 @@ +import Foundation +import Defaults + +extension AppState { + /// Tracks when ports were first seen (port-pid key → first seen date). + /// Stored on AppState as a non-observed property. + private static var portFirstSeen: [String: Date] = [:] + + /// Checks auto-kill rules against current ports and kills matches that exceed their timeout. + func checkAutoKillRules() { + let rules = Defaults[.autoKillRules] + guard !rules.isEmpty else { return } + + let enabledRules = rules.filter(\.isEnabled) + guard !enabledRules.isEmpty else { return } + + let now = Date() + + // Update first-seen tracking + let currentKeys = Set(ports.map { "\($0.port)-\($0.pid)" }) + // Remove stale entries + Self.portFirstSeen = Self.portFirstSeen.filter { currentKeys.contains($0.key) } + // Add new entries + for port in ports { + let key = "\(port.port)-\(port.pid)" + if Self.portFirstSeen[key] == nil { + Self.portFirstSeen[key] = now + } + } + + // Check rules + for port in ports { + let key = "\(port.port)-\(port.pid)" + guard let firstSeen = Self.portFirstSeen[key] else { continue } + + for rule in enabledRules { + guard rule.matches(port) else { continue } + + let elapsed = now.timeIntervalSince(firstSeen) / 60.0 + guard elapsed >= Double(rule.timeoutMinutes) else { continue } + + // Notify and kill + if rule.notifyBeforeKill { + NotificationService.shared.notify( + title: "Auto-Kill: \(port.processName)", + body: "Port \(port.port) killed after \(rule.timeoutMinutes) min (rule: \(rule.name))" + ) + } + + Task { + await killPort(port) + } + + // Remove from tracking so we don't re-trigger + Self.portFirstSeen.removeValue(forKey: key) + break // Only apply first matching rule + } + } + } +} diff --git a/platforms/macos/Sources/AppState+PortOperations.swift b/platforms/macos/Sources/AppState+PortOperations.swift index a783202..585839c 100644 --- a/platforms/macos/Sources/AppState+PortOperations.swift +++ b/platforms/macos/Sources/AppState+PortOperations.swift @@ -27,6 +27,10 @@ extension AppState { // Always update watcher state to keep transition baseline accurate. checkWatchedPorts() + + // Check auto-kill rules + checkAutoKillRules() + isScanning = false } while hasPendingRefreshRequest diff --git a/platforms/macos/Sources/AppState.swift b/platforms/macos/Sources/AppState.swift index 589cb72..c9988d0 100644 --- a/platforms/macos/Sources/AppState.swift +++ b/platforms/macos/Sources/AppState.swift @@ -23,6 +23,9 @@ extension Defaults.Keys { // Process type notification filters (rawValues of enabled types, empty = disabled) static let notifyProcessTypes = Key>("notifyProcessTypes", default: []) + // Auto-kill rules + static let autoKillRules = Key<[AutoKillRule]>("autoKillRules", default: []) + // Kubernetes-related keys static let customNamespaces = Key<[String]>("customNamespaces", default: []) diff --git a/platforms/macos/Sources/Models/AutoKillRule.swift b/platforms/macos/Sources/Models/AutoKillRule.swift new file mode 100644 index 0000000..5a2f282 --- /dev/null +++ b/platforms/macos/Sources/Models/AutoKillRule.swift @@ -0,0 +1,84 @@ +import Foundation +import Defaults + +/// A rule that automatically kills processes matching certain criteria after a timeout. +struct AutoKillRule: Codable, Identifiable, Hashable, Sendable, Defaults.Serializable { + var id: UUID + /// Display name for the rule + var name: String + /// Process name pattern (supports * wildcard, e.g. "node*"). Empty means match any. + var processPattern: String + /// Specific port to match. 0 means match any port. + var port: Int + /// Minutes after which the process should be killed + var timeoutMinutes: Int + /// Whether to notify before killing + var notifyBeforeKill: Bool + /// Whether this rule is active + var isEnabled: Bool + + init( + id: UUID = UUID(), + name: String = "", + processPattern: String = "", + port: Int = 0, + timeoutMinutes: Int = 30, + notifyBeforeKill: Bool = true, + isEnabled: Bool = true + ) { + self.id = id + self.name = name + self.processPattern = processPattern + self.port = port + self.timeoutMinutes = timeoutMinutes + self.notifyBeforeKill = notifyBeforeKill + self.isEnabled = isEnabled + } + + /// Checks if this rule matches a given port info. + func matches(_ portInfo: PortInfo) -> Bool { + // Check port match + if port > 0 && portInfo.port != port { return false } + + // Check process pattern match + if !processPattern.isEmpty { + return matchesGlob(portInfo.processName, pattern: processPattern) + } + + // At least one criterion must be specified + return port > 0 + } + + /// Simple glob matching with * wildcard support. + private func matchesGlob(_ string: String, pattern: String) -> Bool { + let lowered = string.lowercased() + let pat = pattern.lowercased() + + if !pat.contains("*") { + return lowered == pat + } + + let parts = pat.split(separator: "*", omittingEmptySubsequences: false).map(String.init) + + if parts.count == 1 { return lowered == pat } + + // Check prefix + if let first = parts.first, !first.isEmpty, !lowered.hasPrefix(first) { + return false + } + // Check suffix + if let last = parts.last, !last.isEmpty, !lowered.hasSuffix(last) { + return false + } + + // Check all parts appear in order + var searchStart = lowered.startIndex + for part in parts where !part.isEmpty { + guard let range = lowered.range(of: part, range: searchStart.. some View { + SettingsRowContainer { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Circle() + .fill(rule.isEnabled ? .green : .secondary) + .frame(width: 8, height: 8) + Text(rule.name.isEmpty ? "Unnamed Rule" : rule.name) + .fontWeight(.medium) + } + HStack(spacing: 8) { + if !rule.processPattern.isEmpty { + Text("Process: \(rule.processPattern)") + } + if rule.port > 0 { + Text("Port: \(rule.port)") + } + Text("Timeout: \(rule.timeoutMinutes) min") + } + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + HStack(spacing: 8) { + Button { + editingRule = rule + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.plain) + + Button { + rules.removeAll { $0.id == rule.id } + } label: { + Image(systemName: "trash") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + } + } +} + +// MARK: - Rule Editor + +struct AutoKillRuleEditor: View { + @Environment(\.dismiss) private var dismiss + @State var rule: AutoKillRule + let onSave: (AutoKillRule) -> Void + + var body: some View { + VStack(spacing: 0) { + // Header + Text("Edit Auto-Kill Rule") + .font(.headline) + .padding(.top, 20) + + Form { + TextField("Rule Name", text: $rule.name) + + Section("Match Criteria") { + TextField("Process Pattern (e.g. node*, python*)", text: $rule.processPattern) + TextField("Port (0 = any)", value: $rule.port, format: .number) + } + + Section("Behavior") { + Stepper("Timeout: \(rule.timeoutMinutes) minutes", value: $rule.timeoutMinutes, in: 1...1440) + Toggle("Notify before killing", isOn: $rule.notifyBeforeKill) + Toggle("Enabled", isOn: $rule.isEnabled) + } + } + .formStyle(.grouped) + .frame(minHeight: 280) + + // Actions + HStack { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Save") { + onSave(rule) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(rule.processPattern.isEmpty && rule.port == 0) + } + .padding(20) + } + .frame(width: 420) + } +} diff --git a/platforms/macos/Sources/Views/Settings/SettingsView.swift b/platforms/macos/Sources/Views/Settings/SettingsView.swift index a615156..f9173b8 100644 --- a/platforms/macos/Sources/Views/Settings/SettingsView.swift +++ b/platforms/macos/Sources/Views/Settings/SettingsView.swift @@ -36,6 +36,9 @@ struct SettingsView: View { // MARK: - Port Forwarding PortForwardingSettingsSection() + // MARK: - Auto-Kill Rules + AutoKillSettingsSection() + // MARK: - Notifications NotificationsSettingsSection()