Skip to content
Open
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
30 changes: 30 additions & 0 deletions platforms/macos/Sources/AppState+Customizations.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

extension AppState {
/// Returns the customization for a port, if any
func customization(for port: Int) -> PortCustomization? {
customizationsState.customization(for: port)
}

/// Display name for a port row: custom name, or the real process name
func displayName(for port: PortInfo) -> String {
customization(for: port.port)?.name ?? port.processName
}

/// Effective folder for a port: manual override, or detected working directory
func folder(for port: PortInfo) -> String? {
customization(for: port.port)?.folder ?? port.workingDirectory
}

/// Effective type override for a port: per-port record or legacy per-name
func typeOverride(for port: PortInfo) -> ProcessType? {
customizationsState.effectiveTypeOverride(for: port.port, processName: port.processName)
}

/// Sets or clears the per-port type override.
/// Type is baked into PortInfo at scan time, so trigger an immediate rescan.
func setTypeOverride(_ type: ProcessType?, for port: PortInfo) {
customizationsState.setType(type, for: port.port, processName: port.processName)
Task { _ = await refresh() }
}
}
24 changes: 0 additions & 24 deletions platforms/macos/Sources/AppState+PortLabels.swift

This file was deleted.

25 changes: 0 additions & 25 deletions platforms/macos/Sources/AppState+PortNotes.swift

This file was deleted.

12 changes: 9 additions & 3 deletions platforms/macos/Sources/AppState+PortOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,27 @@ extension AppState {
}

/// Updates the internal port list only if there are changes.
/// Compares full values so per-port changes (e.g. a type override
/// resolved at scan time) replace stale entries.
@discardableResult
func updatePorts(_ newPorts: [PortInfo]) -> Bool {
let newSet = Set(newPorts.map { "\($0.port)-\($0.pid)" })
let oldSet = Set(ports.map { "\($0.port)-\($0.pid)" })
guard newSet != oldSet else { return false }
guard Set(newPorts) != Set(ports) else { return false }

ports = newPorts.sorted { a, b in
let aFav = favorites.contains(a.port)
let bFav = favorites.contains(b.port)
if aFav != bFav { return aFav }
return a.port < b.port
}
portsRevision += 1
return true
}

/// Kills the process using the specified port.
/// pid 0 would signal the whole process group (kill(0, …)), so guard
/// against inactive placeholders ever reaching the syscall.
func killPort(_ port: PortInfo) async {
guard port.isActive, port.pid > 0 else { return }
if await scanner.killProcessGracefully(pid: port.pid) {
ports.removeAll { $0.id == port.id }
await refresh()
Expand All @@ -65,6 +69,8 @@ extension AppState {

/// Kills the listening process and all processes with ESTABLISHED connections to the port.
func killPortDeep(_ port: PortInfo) async {
guard port.isActive, port.pid > 0 else { return }

// 1. Kill the listener
_ = await scanner.killProcessGracefully(pid: port.pid)

Expand Down
20 changes: 0 additions & 20 deletions platforms/macos/Sources/AppState+ProcessTypeOverrides.swift

This file was deleted.

38 changes: 31 additions & 7 deletions platforms/macos/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,15 @@ extension Defaults.Keys {
static let portLabels = Key<[String: String]>("portLabels", default: [:])

// Port notes (port number string → freeform note)
// Legacy: migrated into portCustomizations on launch
static let portNotes = Key<[String: String]>("portNotes", default: [:])

// Per-port customizations (port number string → name/description/folder/type)
static let portCustomizations = Key<[String: PortCustomization]>("portCustomizations", default: [:])

// One-time migration flag for portLabels/portNotes → portCustomizations
static let hasMigratedCustomizations = Key<Bool>("hasMigratedCustomizations", default: false)

// Process type notification filters (rawValues of enabled types, empty = disabled)
static let notifyProcessTypes = Key<Set<String>>("notifyProcessTypes", default: [])

Expand Down Expand Up @@ -63,11 +70,19 @@ final class AppState {
/// Manages watched ports (extracted state)
let watchedPortsState: WatchedPortsState

/// Manages per-port customizations (extracted state)
let customizationsState: CustomizationsState

// MARK: - Port State

/// All currently scanned ports
var ports: [PortInfo] = []

/// Bumped whenever `updatePorts` replaces `ports` with changed values.
/// Cache keys use it so any per-port change (not just the first row)
/// invalidates cached filtered results.
var portsRevision = 0

/// Whether a port scan is currently in progress
var isScanning = false

Expand All @@ -82,10 +97,13 @@ final class AppState {
/// ID of the currently selected port in the detail view
var selectedPortID: String? = nil

/// The currently selected port, if any
/// The currently selected port, if any.
/// Falls back to filteredPorts so inactive favorite/watched placeholders
/// (which are synthesized during filtering, never stored in `ports`)
/// can be selected and customized.
var selectedPort: PortInfo? {
guard let id = selectedPortID else { return nil }
return ports.first { $0.id == id }
return ports.first { $0.id == id } ?? filteredPorts.first { $0.id == id }
}

/// ID of the currently selected port-forward connection
Expand Down Expand Up @@ -115,27 +133,29 @@ final class AppState {
/// Cache key to detect when recalculation is needed
private struct FilterCacheKey: Equatable {
let portsCount: Int
let portsHash: Int
let portsRevision: Int
let sidebarItem: SidebarItem
let filterActive: Bool
let filterText: String
let hideSystem: Bool
let favoritesCount: Int
let watchedCount: Int
let customizationsHash: Int
}

/// Returns filtered ports based on sidebar selection and active filters.
/// Uses caching to avoid repeated array allocations on each access.
var filteredPorts: [PortInfo] {
let currentKey = FilterCacheKey(
portsCount: ports.count,
portsHash: ports.isEmpty ? 0 : ports[0].hashValue ^ ports.count,
portsRevision: portsRevision,
sidebarItem: selectedSidebarItem,
filterActive: filter.isActive,
filterText: filter.searchText,
hideSystem: Defaults[.hideSystemProcesses],
favoritesCount: favorites.count,
watchedCount: watchedPorts.count
watchedCount: watchedPorts.count,
customizationsHash: customizationsState.customizations.hashValue
)

// Return cached value if nothing changed
Expand Down Expand Up @@ -184,7 +204,9 @@ final class AppState {
}

if filter.isActive {
result = result.filter { filter.matches($0, favorites: favorites, watched: watchedPorts) }
result = result.filter {
filter.matches($0, favorites: favorites, watched: watchedPorts, customization: customizationsState.customization(for: $0.port))
}
}

if Defaults[.hideSystemProcesses] {
Expand Down Expand Up @@ -246,11 +268,13 @@ final class AppState {
init(
scanner: PortScannerProtocol = PortScanner(),
favoritesState: FavoritesState? = nil,
watchedPortsState: WatchedPortsState? = nil
watchedPortsState: WatchedPortsState? = nil,
customizationsState: CustomizationsState? = nil
) {
self.scanner = scanner
self.favoritesState = favoritesState ?? FavoritesState()
self.watchedPortsState = watchedPortsState ?? WatchedPortsState()
self.customizationsState = customizationsState ?? CustomizationsState()

let cloudflared = CloudflaredService()
self.tunnelManager = TunnelManager(cloudflaredService: cloudflared)
Expand Down
51 changes: 51 additions & 0 deletions platforms/macos/Sources/Models/PortCustomization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* PortCustomization.swift
* PortKiller
*
* Per-port user customization: display name, description, project folder,
* and process type override. Stored in Defaults keyed by port number.
*/

import Foundation
import Defaults

/// User customization for a specific port
///
/// All fields are optional; a record with no fields set is removed from storage.
/// The custom name replaces the process name in port rows, the description is
/// shown in the detail view, the folder overrides the auto-detected working
/// directory, and the type overrides automatic process type detection.
struct PortCustomization: Codable, Hashable, Sendable, Defaults.Serializable {
/// Custom display name shown in place of the process name
var name: String?

/// Brief description shown in the detail view
var description: String?

/// Manually associated folder path (overrides the detected working directory)
var folder: String?

/// Process type override for this port
var type: ProcessType?

/// Whether every field is unset (empty records are dropped from storage)
var isEmpty: Bool {
name == nil && description == nil && folder == nil && type == nil
}

/// Resolve the effective process type for a port
///
/// Resolution order: per-port override → legacy per-process-name override
/// (kept read-only for backwards compatibility) → automatic detection.
///
/// - Parameters:
/// - portOverride: The per-port type override, if any
/// - legacyRaw: Raw value from the legacy `processTypeOverrides` dictionary
/// - processName: The process name for automatic detection
/// - Returns: The effective ProcessType
static func resolveType(portOverride: ProcessType?, legacyRaw: String?, processName: String) -> ProcessType {
portOverride
?? legacyRaw.flatMap(ProcessType.init(rawValue:))
?? ProcessType.detect(from: processName)
}
}
14 changes: 12 additions & 2 deletions platforms/macos/Sources/Models/PortFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ struct PortFilter: Equatable, Sendable {
showOnlyWatched
}

func matches(_ port: PortInfo, favorites: Set<Int>, watched: [WatchedPort]) -> Bool {
func matches(_ port: PortInfo, favorites: Set<Int>, watched: [WatchedPort], customization: PortCustomization? = nil) -> Bool {
// Search text filter
if !searchText.isEmpty {
let query = searchText.lowercased()
Expand All @@ -26,7 +26,9 @@ struct PortFilter: Equatable, Sendable {
String(port.pid).contains(query) ||
port.address.lowercased().contains(query) ||
port.user.lowercased().contains(query) ||
port.command.lowercased().contains(query)
port.command.lowercased().contains(query) ||
(customization?.name?.lowercased().contains(query) ?? false) ||
(customization?.description?.lowercased().contains(query) ?? false)
if !matches { return false }
}

Expand Down Expand Up @@ -92,6 +94,14 @@ enum SidebarItem: Hashable, Identifiable, Sendable {
}
}

/// Whether this selection shows the port list and port detail pane
var showsPorts: Bool {
switch self {
case .allPorts, .favorites, .watched, .processType: return true
case .kubernetesPortForward, .cloudflareTunnels, .sponsors, .settings: return false
}
}

var icon: String {
switch self {
case .allPorts: return "list.bullet"
Expand Down
23 changes: 14 additions & 9 deletions platforms/macos/Sources/Models/PortInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ struct PortInfo: Identifiable, Hashable, Sendable {
/// File descriptor information from lsof
let fd: String

/// Detected working directory of the process (nil if unavailable)
let workingDirectory: String?

/// Whether this port is currently active/listening
let isActive: Bool

Expand All @@ -62,6 +65,7 @@ struct PortInfo: Identifiable, Hashable, Sendable {
user: "-",
command: "",
fd: "",
workingDirectory: nil,
isActive: false,
processType: .other
)
Expand All @@ -77,16 +81,16 @@ struct PortInfo: Identifiable, Hashable, Sendable {
/// - user: Username of the process owner
/// - command: Full command line
/// - fd: File descriptor information
/// - workingDirectory: Detected working directory, if available
/// - Returns: An active PortInfo instance
static func active(port: Int, pid: Int, processName: String, address: String, user: String, command: String, fd: String) -> PortInfo {
// Check for user-defined process type override first
let processType: ProcessType
if let overrideRaw = Defaults[.processTypeOverrides][processName],
let overrideType = ProcessType(rawValue: overrideRaw) {
processType = overrideType
} else {
processType = ProcessType.detect(from: processName)
}
static func active(port: Int, pid: Int, processName: String, address: String, user: String, command: String, fd: String, workingDirectory: String? = nil) -> PortInfo {
// Per-port override → legacy per-name override → auto-detect
let custom = Defaults[.portCustomizations][String(port)]
let processType = PortCustomization.resolveType(
portOverride: custom?.type,
legacyRaw: Defaults[.processTypeOverrides][processName],
processName: processName
)

return PortInfo(
port: port,
Expand All @@ -96,6 +100,7 @@ struct PortInfo: Identifiable, Hashable, Sendable {
user: user,
command: command,
fd: fd,
workingDirectory: workingDirectory,
isActive: true,
processType: processType
)
Expand Down
Loading