Skip to content

Commit 47fa3c9

Browse files
feat: add auto-kill rules for idle ports (#105)
Add configurable rules to automatically kill processes after a timeout. Rules match by process name pattern (glob with * wildcard) and/or port number. Includes optional notification before kill, enable/disable per rule, and a Settings UI for managing rules. Closes #30 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 00fb2b5 commit 47fa3c9

6 files changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Foundation
2+
import Defaults
3+
4+
extension AppState {
5+
/// Tracks when ports were first seen (port-pid key → first seen date).
6+
/// Stored on AppState as a non-observed property.
7+
private static var portFirstSeen: [String: Date] = [:]
8+
9+
/// Checks auto-kill rules against current ports and kills matches that exceed their timeout.
10+
func checkAutoKillRules() {
11+
let rules = Defaults[.autoKillRules]
12+
guard !rules.isEmpty else { return }
13+
14+
let enabledRules = rules.filter(\.isEnabled)
15+
guard !enabledRules.isEmpty else { return }
16+
17+
let now = Date()
18+
19+
// Update first-seen tracking
20+
let currentKeys = Set(ports.map { "\($0.port)-\($0.pid)" })
21+
// Remove stale entries
22+
Self.portFirstSeen = Self.portFirstSeen.filter { currentKeys.contains($0.key) }
23+
// Add new entries
24+
for port in ports {
25+
let key = "\(port.port)-\(port.pid)"
26+
if Self.portFirstSeen[key] == nil {
27+
Self.portFirstSeen[key] = now
28+
}
29+
}
30+
31+
// Check rules
32+
for port in ports {
33+
let key = "\(port.port)-\(port.pid)"
34+
guard let firstSeen = Self.portFirstSeen[key] else { continue }
35+
36+
for rule in enabledRules {
37+
guard rule.matches(port) else { continue }
38+
39+
let elapsed = now.timeIntervalSince(firstSeen) / 60.0
40+
guard elapsed >= Double(rule.timeoutMinutes) else { continue }
41+
42+
// Notify and kill
43+
if rule.notifyBeforeKill {
44+
NotificationService.shared.notify(
45+
title: "Auto-Kill: \(port.processName)",
46+
body: "Port \(port.port) killed after \(rule.timeoutMinutes) min (rule: \(rule.name))"
47+
)
48+
}
49+
50+
Task {
51+
await killPort(port)
52+
}
53+
54+
// Remove from tracking so we don't re-trigger
55+
Self.portFirstSeen.removeValue(forKey: key)
56+
break // Only apply first matching rule
57+
}
58+
}
59+
}
60+
}

platforms/macos/Sources/AppState+PortOperations.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ extension AppState {
2727

2828
// Always update watcher state to keep transition baseline accurate.
2929
checkWatchedPorts()
30+
31+
// Check auto-kill rules
32+
checkAutoKillRules()
33+
3034
isScanning = false
3135
} while hasPendingRefreshRequest
3236

platforms/macos/Sources/AppState.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ extension Defaults.Keys {
2424
// Process type notification filters (rawValues of enabled types, empty = disabled)
2525
static let notifyProcessTypes = Key<Set<String>>("notifyProcessTypes", default: [])
2626

27+
// Auto-kill rules
28+
static let autoKillRules = Key<[AutoKillRule]>("autoKillRules", default: [])
29+
2730
// Kubernetes-related keys
2831
static let customNamespaces = Key<[String]>("customNamespaces", default: [])
2932

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Foundation
2+
import Defaults
3+
4+
/// A rule that automatically kills processes matching certain criteria after a timeout.
5+
struct AutoKillRule: Codable, Identifiable, Hashable, Sendable, Defaults.Serializable {
6+
var id: UUID
7+
/// Display name for the rule
8+
var name: String
9+
/// Process name pattern (supports * wildcard, e.g. "node*"). Empty means match any.
10+
var processPattern: String
11+
/// Specific port to match. 0 means match any port.
12+
var port: Int
13+
/// Minutes after which the process should be killed
14+
var timeoutMinutes: Int
15+
/// Whether to notify before killing
16+
var notifyBeforeKill: Bool
17+
/// Whether this rule is active
18+
var isEnabled: Bool
19+
20+
init(
21+
id: UUID = UUID(),
22+
name: String = "",
23+
processPattern: String = "",
24+
port: Int = 0,
25+
timeoutMinutes: Int = 30,
26+
notifyBeforeKill: Bool = true,
27+
isEnabled: Bool = true
28+
) {
29+
self.id = id
30+
self.name = name
31+
self.processPattern = processPattern
32+
self.port = port
33+
self.timeoutMinutes = timeoutMinutes
34+
self.notifyBeforeKill = notifyBeforeKill
35+
self.isEnabled = isEnabled
36+
}
37+
38+
/// Checks if this rule matches a given port info.
39+
func matches(_ portInfo: PortInfo) -> Bool {
40+
// Check port match
41+
if port > 0 && portInfo.port != port { return false }
42+
43+
// Check process pattern match
44+
if !processPattern.isEmpty {
45+
return matchesGlob(portInfo.processName, pattern: processPattern)
46+
}
47+
48+
// At least one criterion must be specified
49+
return port > 0
50+
}
51+
52+
/// Simple glob matching with * wildcard support.
53+
private func matchesGlob(_ string: String, pattern: String) -> Bool {
54+
let lowered = string.lowercased()
55+
let pat = pattern.lowercased()
56+
57+
if !pat.contains("*") {
58+
return lowered == pat
59+
}
60+
61+
let parts = pat.split(separator: "*", omittingEmptySubsequences: false).map(String.init)
62+
63+
if parts.count == 1 { return lowered == pat }
64+
65+
// Check prefix
66+
if let first = parts.first, !first.isEmpty, !lowered.hasPrefix(first) {
67+
return false
68+
}
69+
// Check suffix
70+
if let last = parts.last, !last.isEmpty, !lowered.hasSuffix(last) {
71+
return false
72+
}
73+
74+
// Check all parts appear in order
75+
var searchStart = lowered.startIndex
76+
for part in parts where !part.isEmpty {
77+
guard let range = lowered.range(of: part, range: searchStart..<lowered.endIndex) else {
78+
return false
79+
}
80+
searchStart = range.upperBound
81+
}
82+
return true
83+
}
84+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import SwiftUI
2+
import Defaults
3+
4+
struct AutoKillSettingsSection: View {
5+
@Default(.autoKillRules) private var rules
6+
@State private var editingRule: AutoKillRule?
7+
@State private var isAddingRule = false
8+
9+
var body: some View {
10+
SettingsGroup("Auto-Kill Rules", icon: "clock.badge.xmark") {
11+
VStack(spacing: 0) {
12+
SettingsRowContainer {
13+
VStack(alignment: .leading, spacing: 2) {
14+
Text("Automatically kill processes after a timeout")
15+
.fontWeight(.medium)
16+
Text("Rules are checked on each port scan cycle")
17+
.font(.caption)
18+
.foregroundStyle(.secondary)
19+
}
20+
}
21+
22+
SettingsDivider()
23+
24+
if rules.isEmpty {
25+
SettingsRowContainer {
26+
HStack {
27+
Text("No rules configured")
28+
.foregroundStyle(.secondary)
29+
Spacer()
30+
Button("Add Rule") {
31+
isAddingRule = true
32+
}
33+
.controlSize(.small)
34+
}
35+
}
36+
} else {
37+
ForEach(rules) { rule in
38+
ruleRow(rule)
39+
if rule.id != rules.last?.id {
40+
SettingsDivider()
41+
}
42+
}
43+
44+
SettingsDivider()
45+
46+
SettingsRowContainer {
47+
HStack {
48+
Spacer()
49+
Button("Add Rule") {
50+
isAddingRule = true
51+
}
52+
.controlSize(.small)
53+
}
54+
}
55+
}
56+
}
57+
}
58+
.sheet(isPresented: $isAddingRule) {
59+
AutoKillRuleEditor(rule: AutoKillRule(name: "New Rule")) { newRule in
60+
rules.append(newRule)
61+
}
62+
}
63+
.sheet(item: $editingRule) { rule in
64+
AutoKillRuleEditor(rule: rule) { updated in
65+
if let index = rules.firstIndex(where: { $0.id == updated.id }) {
66+
rules[index] = updated
67+
}
68+
}
69+
}
70+
}
71+
72+
private func ruleRow(_ rule: AutoKillRule) -> some View {
73+
SettingsRowContainer {
74+
HStack {
75+
VStack(alignment: .leading, spacing: 2) {
76+
HStack(spacing: 6) {
77+
Circle()
78+
.fill(rule.isEnabled ? .green : .secondary)
79+
.frame(width: 8, height: 8)
80+
Text(rule.name.isEmpty ? "Unnamed Rule" : rule.name)
81+
.fontWeight(.medium)
82+
}
83+
HStack(spacing: 8) {
84+
if !rule.processPattern.isEmpty {
85+
Text("Process: \(rule.processPattern)")
86+
}
87+
if rule.port > 0 {
88+
Text("Port: \(rule.port)")
89+
}
90+
Text("Timeout: \(rule.timeoutMinutes) min")
91+
}
92+
.font(.caption)
93+
.foregroundStyle(.secondary)
94+
}
95+
96+
Spacer()
97+
98+
HStack(spacing: 8) {
99+
Button {
100+
editingRule = rule
101+
} label: {
102+
Image(systemName: "pencil")
103+
}
104+
.buttonStyle(.plain)
105+
106+
Button {
107+
rules.removeAll { $0.id == rule.id }
108+
} label: {
109+
Image(systemName: "trash")
110+
.foregroundStyle(.red)
111+
}
112+
.buttonStyle(.plain)
113+
}
114+
}
115+
}
116+
}
117+
}
118+
119+
// MARK: - Rule Editor
120+
121+
struct AutoKillRuleEditor: View {
122+
@Environment(\.dismiss) private var dismiss
123+
@State var rule: AutoKillRule
124+
let onSave: (AutoKillRule) -> Void
125+
126+
var body: some View {
127+
VStack(spacing: 0) {
128+
// Header
129+
Text("Edit Auto-Kill Rule")
130+
.font(.headline)
131+
.padding(.top, 20)
132+
133+
Form {
134+
TextField("Rule Name", text: $rule.name)
135+
136+
Section("Match Criteria") {
137+
TextField("Process Pattern (e.g. node*, python*)", text: $rule.processPattern)
138+
TextField("Port (0 = any)", value: $rule.port, format: .number)
139+
}
140+
141+
Section("Behavior") {
142+
Stepper("Timeout: \(rule.timeoutMinutes) minutes", value: $rule.timeoutMinutes, in: 1...1440)
143+
Toggle("Notify before killing", isOn: $rule.notifyBeforeKill)
144+
Toggle("Enabled", isOn: $rule.isEnabled)
145+
}
146+
}
147+
.formStyle(.grouped)
148+
.frame(minHeight: 280)
149+
150+
// Actions
151+
HStack {
152+
Button("Cancel") {
153+
dismiss()
154+
}
155+
.keyboardShortcut(.cancelAction)
156+
157+
Spacer()
158+
159+
Button("Save") {
160+
onSave(rule)
161+
dismiss()
162+
}
163+
.keyboardShortcut(.defaultAction)
164+
.disabled(rule.processPattern.isEmpty && rule.port == 0)
165+
}
166+
.padding(20)
167+
}
168+
.frame(width: 420)
169+
}
170+
}

platforms/macos/Sources/Views/Settings/SettingsView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ struct SettingsView: View {
3636
// MARK: - Port Forwarding
3737
PortForwardingSettingsSection()
3838

39+
// MARK: - Auto-Kill Rules
40+
AutoKillSettingsSection()
41+
3942
// MARK: - Notifications
4043
NotificationsSettingsSection()
4144

0 commit comments

Comments
 (0)