Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions platforms/macos/Sources/AppState+AutoKill.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
4 changes: 4 additions & 0 deletions platforms/macos/Sources/AppState+PortOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions platforms/macos/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ extension Defaults.Keys {
// Process type notification filters (rawValues of enabled types, empty = disabled)
static let notifyProcessTypes = Key<Set<String>>("notifyProcessTypes", default: [])

// Auto-kill rules
static let autoKillRules = Key<[AutoKillRule]>("autoKillRules", default: [])

// Kubernetes-related keys
static let customNamespaces = Key<[String]>("customNamespaces", default: [])

Expand Down
84 changes: 84 additions & 0 deletions platforms/macos/Sources/Models/AutoKillRule.swift
Original file line number Diff line number Diff line change
@@ -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..<lowered.endIndex) else {
return false
}
searchStart = range.upperBound
}
return true
}
}
170 changes: 170 additions & 0 deletions platforms/macos/Sources/Views/Settings/AutoKillSettingsSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import SwiftUI
import Defaults

struct AutoKillSettingsSection: View {
@Default(.autoKillRules) private var rules
@State private var editingRule: AutoKillRule?
@State private var isAddingRule = false

var body: some View {
SettingsGroup("Auto-Kill Rules", icon: "clock.badge.xmark") {
VStack(spacing: 0) {
SettingsRowContainer {
VStack(alignment: .leading, spacing: 2) {
Text("Automatically kill processes after a timeout")
.fontWeight(.medium)
Text("Rules are checked on each port scan cycle")
.font(.caption)
.foregroundStyle(.secondary)
}
}

SettingsDivider()

if rules.isEmpty {
SettingsRowContainer {
HStack {
Text("No rules configured")
.foregroundStyle(.secondary)
Spacer()
Button("Add Rule") {
isAddingRule = true
}
.controlSize(.small)
}
}
} else {
ForEach(rules) { rule in
ruleRow(rule)
if rule.id != rules.last?.id {
SettingsDivider()
}
}

SettingsDivider()

SettingsRowContainer {
HStack {
Spacer()
Button("Add Rule") {
isAddingRule = true
}
.controlSize(.small)
}
}
}
}
}
.sheet(isPresented: $isAddingRule) {
AutoKillRuleEditor(rule: AutoKillRule(name: "New Rule")) { newRule in
rules.append(newRule)
}
}
.sheet(item: $editingRule) { rule in
AutoKillRuleEditor(rule: rule) { updated in
if let index = rules.firstIndex(where: { $0.id == updated.id }) {
rules[index] = updated
}
}
}
}

private func ruleRow(_ rule: AutoKillRule) -> 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)
}
}
3 changes: 3 additions & 0 deletions platforms/macos/Sources/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ struct SettingsView: View {
// MARK: - Port Forwarding
PortForwardingSettingsSection()

// MARK: - Auto-Kill Rules
AutoKillSettingsSection()

// MARK: - Notifications
NotificationsSettingsSection()

Expand Down
Loading