diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 804b8eb..fe1dc98 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -13,6 +13,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { var showSystemProcesses = UserDefaults.standard.bool(forKey: "showSystemProcesses") var showOnlyDevProcesses = UserDefaults.standard.bool(forKey: "showOnlyDevProcesses") var cachedProcesses: [ProcessInfo] = [] + var showHiddenProcesses = false private func createInfoMenuItem(title: String, value: String, font: NSFont, color: NSColor, maxLength: Int = 0) -> NSMenuItem { let text: String @@ -127,7 +128,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let allProcesses = portScanner.scanPorts(showSystemProcesses: true) cachedProcesses = allProcesses - let processes: [ProcessInfo] + var processes: [ProcessInfo] if showOnlyDevProcesses { processes = allProcesses.filter { portScanner.isDevProcess($0) } } else if showSystemProcesses { @@ -135,121 +136,35 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } else { processes = allProcesses.filter { !portScanner.isSystemProcess($0.name) } } + + // Separate hidden and visible processes + let hiddenProcesses = processes.filter { $0.isHidden } + processes = processes.filter { !$0.isHidden } + _ = Date().timeIntervalSince(scanStart) - // if debugTiming { print("Portsly: Port scanning took \(scanTime)s") } - if processes.isEmpty { + if processes.isEmpty && hiddenProcesses.isEmpty { let item = NSMenuItem(title: "No applications listening on ports", action: nil, keyEquivalent: "") item.isEnabled = false menu.addItem(item) } else { - // Create a map of ports to processes for easier lookup - var portToProcesses: [Int: [(name: String, pid: Int, workingDirectory: String?, fullCommand: String?)]] = [:] - - for process in processes { - for port in process.ports { - if portToProcesses[port] == nil { - portToProcesses[port] = [] - } - portToProcesses[port]?.append((name: process.name, pid: process.pid, workingDirectory: process.workingDirectory, fullCommand: process.fullCommand)) - } - } - - // Sort ports and create menu items - let sortedPorts = portToProcesses.keys.sorted() + // Build port menu for visible processes + buildPortMenuItems(menu: menu, processes: processes) - for port in sortedPorts { - guard let processesOnPort = portToProcesses[port] else { continue } + // Add hidden processes submenu if there are any + if !hiddenProcesses.isEmpty { + menu.addItem(NSMenuItem.separator()) - // Format the menu item with port on left, process name(s) on right - let processNames = processesOnPort.map { $0.name }.joined(separator: ", ") - let title = String(format: "%-8d %@", port, processNames) + let hiddenSubmenuItem = NSMenuItem(title: "Hidden (\(hiddenProcesses.count))", action: nil, keyEquivalent: "") + hiddenSubmenuItem.image = createHiddenIcon() + let hiddenSubmenu = NSMenu() + hiddenSubmenu.autoenablesItems = false - let portItem = NSMenuItem(title: title, action: nil, keyEquivalent: "") - // Use attributed title for monospaced font - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) - ] - portItem.attributedTitle = NSAttributedString(string: title, attributes: attributes) + // Build port menu items for hidden processes + buildPortMenuItems(menu: hiddenSubmenu, processes: hiddenProcesses, isHiddenSection: true) - // Set icon if we have one (use the first process's icon) - if let firstProcess = processes.first(where: { $0.ports.contains(port) }), - let icon = firstProcess.icon { - portItem.image = icon - } else { - // Create a blank icon for alignment - let blankIcon = NSImage(size: NSSize(width: 16, height: 16)) - blankIcon.lockFocus() - NSColor.clear.set() - NSRect(x: 0, y: 0, width: 16, height: 16).fill() - blankIcon.unlockFocus() - portItem.image = blankIcon - } - - // If multiple processes on same port, or user wants to see details - if processesOnPort.count > 1 || true { // Always show submenu for consistency - let submenu = NSMenu() - submenu.autoenablesItems = false - - // Add "Open in Browser" at the top - let openItem = NSMenuItem(title: "Open in Browser", action: #selector(openInBrowser(_:)), keyEquivalent: "") - openItem.representedObject = port - openItem.target = self - openItem.isEnabled = true - submenu.addItem(openItem) - - submenu.addItem(NSMenuItem.separator()) - - for (name, pid, workingDirectory, fullCommand) in processesOnPort { - submenu.addItem(createInfoMenuItem( - title: "PID", - value: String(pid), - font: .systemFont(ofSize: 12), - color: .secondaryLabelColor - )) - - if let cwd = workingDirectory { - submenu.addItem(createInfoMenuItem( - title: "Directory", - value: cwd, - font: .monospacedSystemFont(ofSize: 12, weight: .regular), - color: .secondaryLabelColor - )) - } - - if let cmd = fullCommand { - submenu.addItem(createInfoMenuItem( - title: "Command", - value: cmd, - font: .monospacedSystemFont(ofSize: 12, weight: .regular), - color: .secondaryLabelColor, - maxLength: 50 - )) - } - - let killItem = NSMenuItem(title: "Kill \(name)", action: #selector(killProcess(_:)), keyEquivalent: "") - killItem.representedObject = ["pid": pid, "force": false] - killItem.target = self - killItem.isEnabled = true - submenu.addItem(killItem) - - let forceKillItem = NSMenuItem(title: "Force Quit \(name)", action: #selector(killProcess(_:)), keyEquivalent: "") - forceKillItem.representedObject = ["pid": pid, "force": true] - forceKillItem.target = self - forceKillItem.isEnabled = true - submenu.addItem(forceKillItem) - - if processesOnPort.count > 1, - let last = processesOnPort.last, - (name != last.name || pid != last.pid) { - submenu.addItem(NSMenuItem.separator()) - } - } - - portItem.submenu = submenu - } - - menu.addItem(portItem) + hiddenSubmenuItem.submenu = hiddenSubmenu + menu.addItem(hiddenSubmenuItem) } } @@ -281,6 +196,199 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { print(String(format: "[%.0fms] Portsly: Total menu update", totalTimeMs)) } + private func buildPortMenuItems(menu: NSMenu, processes: [ProcessInfo], isHiddenSection: Bool = false) { + // Create a map of ports to processes for easier lookup + var portToProcesses: [Int: [(process: ProcessInfo, name: String, pid: Int)]] = [:] + + for process in processes { + for port in process.ports { + if portToProcesses[port] == nil { + portToProcesses[port] = [] + } + portToProcesses[port]?.append((process: process, name: process.displayName, pid: process.pid)) + } + } + + // Sort ports and create menu items + let sortedPorts = portToProcesses.keys.sorted() + + for port in sortedPorts { + guard let processesOnPort = portToProcesses[port] else { continue } + + // Get first process for this port to check customization + let firstProcess = processesOnPort.first!.process + + // Format the menu item with port on left, display name(s) on right + let processNames = processesOnPort.map { $0.name }.joined(separator: ", ") + let title = String(format: "%-8d %@", port, processNames) + + let portItem = NSMenuItem(title: title, action: nil, keyEquivalent: "") + + // Use attributed title for monospaced font + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + ] + portItem.attributedTitle = NSAttributedString(string: title, attributes: attributes) + + // Set icon if we have one + if let icon = firstProcess.icon { + portItem.image = icon + } else { + portItem.image = createBlankIcon() + } + + // Add tooltip with working directory + if let workingDir = firstProcess.workingDirectory { + portItem.toolTip = "Directory: \(workingDir)" + } + + let submenu = NSMenu() + submenu.autoenablesItems = false + + // Add "Open in Browser" at the top + let openItem = NSMenuItem(title: "Open in Browser", action: #selector(openInBrowser(_:)), keyEquivalent: "") + openItem.representedObject = port + openItem.target = self + openItem.isEnabled = true + submenu.addItem(openItem) + + submenu.addItem(NSMenuItem.separator()) + + for (process, name, pid) in processesOnPort { + submenu.addItem(createInfoMenuItem( + title: "PID", + value: String(pid), + font: .systemFont(ofSize: 12), + color: .secondaryLabelColor + )) + + if let cwd = process.workingDirectory { + submenu.addItem(createInfoMenuItem( + title: "Directory", + value: cwd, + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + color: .secondaryLabelColor, + maxLength: 50 + )) + } + + if let cmd = process.fullCommand { + submenu.addItem(createInfoMenuItem( + title: "Command", + value: cmd, + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + color: .secondaryLabelColor, + maxLength: 50 + )) + } + + // Add Rename option + let renameItem = NSMenuItem(title: "Rename...", action: #selector(renameProcess(_:)), keyEquivalent: "") + renameItem.representedObject = process.customizationKey + renameItem.target = self + renameItem.isEnabled = true + submenu.addItem(renameItem) + + // Add Hide/Unhide option + let hideTitle = process.isHidden ? "Unhide" : "Hide" + let hideItem = NSMenuItem(title: hideTitle, action: #selector(toggleHideProcess(_:)), keyEquivalent: "") + hideItem.representedObject = process.customizationKey + hideItem.target = self + hideItem.isEnabled = true + submenu.addItem(hideItem) + + submenu.addItem(NSMenuItem.separator()) + + let killItem = NSMenuItem(title: "Kill \(name)", action: #selector(killProcess(_:)), keyEquivalent: "") + killItem.representedObject = ["pid": pid, "force": false] + killItem.target = self + killItem.isEnabled = true + submenu.addItem(killItem) + + let forceKillItem = NSMenuItem(title: "Force Quit \(name)", action: #selector(killProcess(_:)), keyEquivalent: "") + forceKillItem.representedObject = ["pid": pid, "force": true] + forceKillItem.target = self + forceKillItem.isEnabled = true + submenu.addItem(forceKillItem) + + if processesOnPort.count > 1, + let last = processesOnPort.last, + (name != last.name || pid != last.pid) { + submenu.addItem(NSMenuItem.separator()) + } + } + + portItem.submenu = submenu + menu.addItem(portItem) + } + } + + private func renameProcessInternal(customizationKey: String) { + let alert = NSAlert() + alert.messageText = "Rename Process" + alert.informativeText = "Enter a custom name for this process:" + alert.alertStyle = .informational + + // Add text field + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) + textField.placeholderString = "Custom name (leave empty to reset)" + + // Get current custom name if exists + if let customization = ProcessCustomizationManager.shared.getCustomization(forKey: customizationKey) { + textField.stringValue = customization.customName ?? "" + } + + alert.accessoryView = textField + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Reset") + alert.addButton(withTitle: "Cancel") + + // Focus text field + DispatchQueue.main.async { + textField.becomeFirstResponder() + } + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + // OK - set custom name + let newName = textField.stringValue.trimmingCharacters(in: .whitespaces) + if newName.isEmpty { + ProcessCustomizationManager.shared.setCustomName(nil, forKey: customizationKey) + } else { + ProcessCustomizationManager.shared.setCustomName(newName, forKey: customizationKey) + } + updateMenu() + } else if response == .alertSecondButtonReturn { + // Reset - remove custom name + ProcessCustomizationManager.shared.setCustomName(nil, forKey: customizationKey) + updateMenu() + } + } + + private func createBlankIcon() -> NSImage { + let blankIcon = NSImage(size: NSSize(width: 16, height: 16)) + blankIcon.lockFocus() + NSColor.clear.set() + NSRect(x: 0, y: 0, width: 16, height: 16).fill() + blankIcon.unlockFocus() + return blankIcon + } + + private func createHiddenIcon() -> NSImage { + let icon = NSImage(size: NSSize(width: 16, height: 16)) + icon.lockFocus() + let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) + if let eyeSlash = NSImage(systemSymbolName: "eye.slash", accessibilityDescription: nil)? + .withSymbolConfiguration(config) { + eyeSlash.draw(in: NSRect(x: 0, y: 0, width: 16, height: 16)) + } else { + NSColor.gray.set() + NSRect(x: 2, y: 6, width: 12, height: 4).fill() + } + icon.unlockFocus() + return icon + } + @objc func killProcess(_ sender: NSMenuItem) { guard let info = sender.representedObject as? [String: Any], let pid = info["pid"] as? Int, @@ -306,6 +414,23 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + @objc func renameProcess(_ sender: NSMenuItem) { + guard let customizationKey = sender.representedObject as? String else { return } + renameProcessInternal(customizationKey: customizationKey) + } + + @objc func toggleHideProcess(_ sender: NSMenuItem) { + guard let customizationKey = sender.representedObject as? String else { return } + _ = ProcessCustomizationManager.shared.toggleHidden(forKey: customizationKey) + updateMenu() + } + + @objc func toggleShowHidden(_ sender: NSMenuItem) { + showHiddenProcesses.toggle() + sender.state = showHiddenProcesses ? .on : .off + filterAndUpdateMenu() + } + @objc func toggleSystemProcesses(_ sender: NSMenuItem) { showSystemProcesses.toggle() sender.state = showSystemProcesses ? .on : .off @@ -343,7 +468,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { guard let menu = statusItem?.menu else { return } // Filter cached processes based on settings - let processes: [ProcessInfo] + var processes: [ProcessInfo] if showOnlyDevProcesses { processes = cachedProcesses.filter { portScanner.isDevProcess($0) } } else if showSystemProcesses { @@ -352,128 +477,41 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { processes = cachedProcesses.filter { !portScanner.isSystemProcess($0.name) } } + // Separate hidden and visible processes + let hiddenProcesses = processes.filter { $0.isHidden } + processes = processes.filter { !$0.isHidden } + // Update menu with cached data (super fast) - rebuildMenuWithProcesses(menu: menu, processes: processes) + rebuildMenuWithProcesses(menu: menu, processes: processes, hiddenProcesses: hiddenProcesses) } - private func rebuildMenuWithProcesses(menu: NSMenu, processes: [ProcessInfo]) { + private func rebuildMenuWithProcesses(menu: NSMenu, processes: [ProcessInfo], hiddenProcesses: [ProcessInfo] = []) { // Clear existing items menu.removeAllItems() menu.autoenablesItems = false - if processes.isEmpty { + if processes.isEmpty && hiddenProcesses.isEmpty { let item = NSMenuItem(title: "No applications listening on ports", action: nil, keyEquivalent: "") item.isEnabled = false menu.addItem(item) } else { - // Create a map of ports to processes for easier lookup - var portToProcesses: [Int: [(name: String, pid: Int, workingDirectory: String?, fullCommand: String?)]] = [:] - - for process in processes { - for port in process.ports { - if portToProcesses[port] == nil { - portToProcesses[port] = [] - } - portToProcesses[port]?.append((name: process.name, pid: process.pid, workingDirectory: process.workingDirectory, fullCommand: process.fullCommand)) - } - } + // Build port menu for visible processes + buildPortMenuItems(menu: menu, processes: processes) - // Sort ports and create menu items - let sortedPorts = portToProcesses.keys.sorted() + // Add hidden processes submenu if there are any + if !hiddenProcesses.isEmpty { + menu.addItem(NSMenuItem.separator()) - for port in sortedPorts { - guard let processesOnPort = portToProcesses[port] else { continue } + let hiddenSubmenuItem = NSMenuItem(title: "Hidden (\(hiddenProcesses.count))", action: nil, keyEquivalent: "") + hiddenSubmenuItem.image = createHiddenIcon() + let hiddenSubmenu = NSMenu() + hiddenSubmenu.autoenablesItems = false - // Format the menu item with port on left, process name(s) on right - let processNames = processesOnPort.map { $0.name }.joined(separator: ", ") - let title = String(format: "%-8d %@", port, processNames) - - let portItem = NSMenuItem(title: title, action: nil, keyEquivalent: "") - // Use attributed title for monospaced font - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) - ] - portItem.attributedTitle = NSAttributedString(string: title, attributes: attributes) - - // Set icon if we have one (use the first process's icon) - if let firstProcess = processes.first(where: { $0.ports.contains(port) }), - let icon = firstProcess.icon { - portItem.image = icon - } else { - // Create a blank icon for alignment - let blankIcon = NSImage(size: NSSize(width: 16, height: 16)) - blankIcon.lockFocus() - NSColor.clear.set() - NSRect(x: 0, y: 0, width: 16, height: 16).fill() - blankIcon.unlockFocus() - portItem.image = blankIcon - } - - // If multiple processes on same port, or user wants to see details - if processesOnPort.count > 1 || true { // Always show submenu for consistency - let submenu = NSMenu() - submenu.autoenablesItems = false - - // Add "Open in Browser" at the top - let openItem = NSMenuItem(title: "Open in Browser", action: #selector(openInBrowser(_:)), keyEquivalent: "") - openItem.representedObject = port - openItem.target = self - openItem.isEnabled = true - submenu.addItem(openItem) - - submenu.addItem(NSMenuItem.separator()) - - for (name, pid, workingDirectory, fullCommand) in processesOnPort { - submenu.addItem(createInfoMenuItem( - title: "PID", - value: String(pid), - font: .systemFont(ofSize: 12), - color: .secondaryLabelColor - )) - - if let cwd = workingDirectory { - submenu.addItem(createInfoMenuItem( - title: "Directory", - value: cwd, - font: .monospacedSystemFont(ofSize: 12, weight: .regular), - color: .secondaryLabelColor, - maxLength: 50 - )) - } - - if let cmd = fullCommand { - submenu.addItem(createInfoMenuItem( - title: "Command", - value: cmd, - font: .monospacedSystemFont(ofSize: 12, weight: .regular), - color: .secondaryLabelColor, - maxLength: 50 - )) - } - - let killItem = NSMenuItem(title: "Kill \(name)", action: #selector(killProcess(_:)), keyEquivalent: "") - killItem.representedObject = ["pid": pid, "force": false] - killItem.target = self - killItem.isEnabled = true - submenu.addItem(killItem) - - let forceKillItem = NSMenuItem(title: "Force Quit \(name)", action: #selector(killProcess(_:)), keyEquivalent: "") - forceKillItem.representedObject = ["pid": pid, "force": true] - forceKillItem.target = self - forceKillItem.isEnabled = true - submenu.addItem(forceKillItem) - - if processesOnPort.count > 1, - let last = processesOnPort.last, - (name != last.name || pid != last.pid) { - submenu.addItem(NSMenuItem.separator()) - } - } - - portItem.submenu = submenu - } + // Build port menu items for hidden processes + buildPortMenuItems(menu: hiddenSubmenu, processes: hiddenProcesses, isHiddenSection: true) - menu.addItem(portItem) + hiddenSubmenuItem.submenu = hiddenSubmenu + menu.addItem(hiddenSubmenuItem) } } diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift index 96f82c4..1ae1537 100644 --- a/Sources/PortScanner.swift +++ b/Sources/PortScanner.swift @@ -15,6 +15,82 @@ struct ProcessInfo { let ports: [Int] let workingDirectory: String? let icon: NSImage? + + // Custom display name (user-defined) + var customName: String? + var isHidden: Bool = false + + var displayName: String { + return customName ?? name + } + + // Unique key for customization (directory + command) + var customizationKey: String { + let dir = workingDirectory ?? "/" + let cmd = fullCommand ?? name + return "\(dir)|\(cmd)" + } +} + +// Process customization stored by working directory +struct ProcessCustomization: Codable { + var customName: String? + var isHidden: Bool = false +} + +class ProcessCustomizationManager { + static let shared = ProcessCustomizationManager() + + private let userDefaultsKey = "processCustomizations" + private var customizations: [String: ProcessCustomization] = [:] + + private init() { + load() + } + + private func load() { + guard let data = UserDefaults.standard.data(forKey: userDefaultsKey), + let decoded = try? JSONDecoder().decode([String: ProcessCustomization].self, from: data) else { + return + } + customizations = decoded + } + + private func save() { + guard let data = try? JSONEncoder().encode(customizations) else { return } + UserDefaults.standard.set(data, forKey: userDefaultsKey) + } + + func getCustomization(forKey key: String) -> ProcessCustomization? { + return customizations[key] + } + + func setCustomName(_ name: String?, forKey key: String) { + if customizations[key] == nil { + customizations[key] = ProcessCustomization() + } + customizations[key]?.customName = name + save() + } + + func setHidden(_ hidden: Bool, forKey key: String) { + if customizations[key] == nil { + customizations[key] = ProcessCustomization() + } + customizations[key]?.isHidden = hidden + save() + } + + func toggleHidden(forKey key: String) -> Bool { + let current = customizations[key]?.isHidden ?? false + setHidden(!current, forKey: key) + return !current + } + + func removeCustomization(forKey key: String) { + customizations.removeValue(forKey: key) + save() + } } class PortScanner { @@ -458,24 +534,47 @@ class PortScanner { // Combine regular processes and Docker containers - var allProcesses = processMap.map { pid, info in - ProcessInfo(pid: pid, name: info.name, fullCommand: info.fullCommand, ports: Array(info.ports).sorted(), workingDirectory: info.workingDirectory, icon: pidToIcon[pid] ?? nil) + let customizationManager = ProcessCustomizationManager.shared + var allProcesses = processMap.map { pid, info -> ProcessInfo in + var process = ProcessInfo( + pid: pid, + name: info.name, + fullCommand: info.fullCommand, + ports: Array(info.ports).sorted(), + workingDirectory: info.workingDirectory, + icon: pidToIcon[pid] ?? nil + ) + let customization = customizationManager.getCustomization(forKey: process.customizationKey) + process.customName = customization?.customName + process.isHidden = customization?.isHidden ?? false + return process } // Add Docker containers as separate ProcessInfo entries - allProcesses += dockerContainerMap.map { containerName, info in - ProcessInfo(pid: info.pid, name: containerName, fullCommand: nil, ports: Array(info.ports).sorted(), workingDirectory: info.workingDirectory, icon: pidToIcon[info.pid] ?? nil) + allProcesses += dockerContainerMap.map { containerName, info -> ProcessInfo in + var process = ProcessInfo( + pid: info.pid, + name: containerName, + fullCommand: nil, + ports: Array(info.ports).sorted(), + workingDirectory: info.workingDirectory, + icon: pidToIcon[info.pid] ?? nil + ) + let customization = customizationManager.getCustomization(forKey: process.customizationKey) + process.customName = customization?.customName + process.isHidden = customization?.isHidden ?? false + return process } let result: [ProcessInfo] if showSystemProcesses { - result = allProcesses.sorted { $0.name < $1.name } + result = allProcesses.sorted { $0.displayName < $1.displayName } } else { result = allProcesses .filter { process in !isSystemProcess(process.name) } - .sorted { $0.name < $1.name } + .sorted { $0.displayName < $1.displayName } } if debugTiming {