{
  "format" : 1,
  "manifest" : {
    "capabilities" : [
      {
        "run" : {
          "commands" : [
            "lsof",
            "kill"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Ports",
    "id" : "io.navbytes.stackbar.plugins.ports",
    "minimumStackbar" : "0.11.0",
    "name" : "Ports",
    "summary" : "Which process is listening on which port, and a way to stop it.",
    "surfaces" : [
      "panel",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 30
        }
      }
    ],
    "version" : "1.0.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "6Zfs4nHstSJzkB206MLlxbrZQxH1RTthb//rqeFs1SO8Ri2c8VF2SblPvHIVA0z0tfoduVI0OKUnBqohO8GdAQ==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// What is listening on which port, and a way to stop it.\n///\n/// \"Kill Port 3000\" from the palette ends whatever holds the port that a dev\n/// server forgot to let go of. The panel lists every listening TCP port with\n/// its process, from `lsof`, run through Stackbar every 30 seconds while the\n/// panel is open. Stopping sends SIGTERM, and SIGKILL only when you ask.\n@main\nfinal class Ports: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.ports\",\n        name: \"Ports\",\n        version: \"1.0.0\",\n        summary: \"Which process is listening on which port, and a way to stop it.\",\n        surfaces: [.panel, .settings],\n        triggers: [.launch, .every(.seconds(30))],\n        capabilities: [.run(commands: [\"lsof\", \"kill\"])],\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Ports\"\n    )\n\n    struct Listener: Equatable, Identifiable {\n        var port: Int\n        var address: String\n        var pid: Int\n        var process: String\n        var user: String\n        var id: String { \"\\(pid):\\(address):\\(port)\" }\n        var isLocalOnly: Bool { address.hasPrefix(\"127.\") || address == \"[::1]\" || address == \"localhost\" }\n    }\n\n    var listeners: [Listener] = []\n    var loaded = false\n    var problem: String?\n    var notice: String?\n\n    @Stored(\"hideSystem\") var hideSystem = true\n    @Stored(\"minimumPort\") var minimumPort = 1024\n\n    var shown: [Listener] {\n        listeners.filter { $0.port >= (hideSystem ? minimumPort : 0) }\n    }\n\n    func refresh() async throws {\n        await load()\n    }\n\n    func load() async {\n        do {\n            // -F prints one field a line: p (pid), c (command), L (login), n (name).\n            let result = try await Stackbar.run(\"lsof\", [\"-nP\", \"-iTCP\", \"-sTCP:LISTEN\", \"-FpcLn\"], timeout: .seconds(20))\n            // lsof exits 1 when nothing matches.\n            guard result.succeeded || (result.status == 1 && result.errorText.isEmpty) else {\n                problem = \"lsof failed (\\(result.status)): \\(result.errorText.prefix(200))\"\n                loaded = true\n                return\n            }\n            listeners = Self.parse(result.text)\n            problem = nil\n        } catch {\n            problem = \"\\(error)\"\n        }\n        loaded = true\n    }\n\n    /// Parses `lsof -F pcLn` output; one entry per process and port.\n    static func parse(_ output: String) -> [Listener] {\n        var found: [Listener] = []\n        var pid = 0\n        var command = \"\"\n        var user = \"\"\n        for line in output.split(separator: \"\\n\") {\n            guard let field = line.first else { continue }\n            let value = String(line.dropFirst())\n            switch field {\n            case \"p\":\n                pid = Int(value) ?? 0\n                command = \"\"\n                user = \"\"\n            case \"c\": command = value\n            case \"L\": user = value\n            case \"n\":\n                // *:3000, 127.0.0.1:5432, [::1]:8080\n                guard let colon = value.lastIndex(of: \":\"), let port = Int(value[value.index(after: colon)...]) else { continue }\n                let address = String(value[..<colon])\n                let listener = Listener(port: port, address: address == \"*\" ? \"all interfaces\" : address, pid: pid, process: command, user: user)\n                if !found.contains(where: { $0.pid == pid && $0.port == port }) { found.append(listener) }\n            default: break\n            }\n        }\n        return found.sorted { ($0.port, $0.pid) < ($1.port, $1.pid) }\n    }\n\n    func stop(_ listener: Listener, force: Bool) async throws -> String {\n        let result = try await Stackbar.run(\"kill\", [force ? \"-KILL\" : \"-TERM\", \"\\(listener.pid)\"], timeout: .seconds(10))\n        guard result.succeeded else {\n            let reason = result.errorText.contains(\"not permitted\") ? \"it belongs to another user\" : result.errorText.trimmingCharacters(in: .whitespacesAndNewlines)\n            throw HostError(\"Couldn't stop \\(listener.process) (\\(listener.pid)): \\(reason)\")\n        }\n        try? await Task.sleep(for: .milliseconds(400))\n        await load()\n        let message = (listeners.contains { $0.pid == listener.pid } ? \"Asked \\(listener.process) to stop; it is still listening\" : \"Stopped \\(listener.process) on port \\(listener.port)\")\n        notice = message\n        return message\n    }\n\n    // MARK: Surfaces\n\n    var panel: ViewContent {\n        if let problem {\n            ContentUnavailableView(\"Can't list ports\", symbol: \"exclamationmark.triangle\", message: problem)\n        } else if !loaded {\n            ContentUnavailableView(\"Loading…\", symbol: \"network\")\n        } else if shown.isEmpty {\n            ContentUnavailableView(\"Nothing is listening\", symbol: \"network\", message: hideSystem ? \"Ports under \\(minimumPort) are hidden in Settings.\" : nil)\n        } else {\n            List(shown, searchable: true) { listener in self.row(listener) }\n            if let notice { Text(notice).style(.caption) }\n        }\n    }\n\n    func row(_ listener: Listener) -> Row {\n        Row(\":\\(listener.port)\", subtitle: \"\\(listener.process) · pid \\(listener.pid) · \\(listener.address)\", symbol: \"network\", key: listener.id)\n            .badge(listener.isLocalOnly ? \"local\" : \"open\", role: listener.isLocalOnly ? .secondary : .warning)\n            .action(\"Open in Browser\") { Stackbar.open(\"http://localhost:\\(listener.port)\") }\n            .action(\"Stop Process\", shortcut: \"cmd+.\", role: .critical) { _ = try await self.stop(listener, force: false) }\n            .action(\"Force Quit Process\", shortcut: \"cmd+shift+.\", role: .critical) { _ = try await self.stop(listener, force: true) }\n            .action(\"Copy PID\", shortcut: \"cmd+shift+c\") { Stackbar.copy(\"\\(listener.pid)\") }\n    }\n\n    var settings: ViewContent {\n        Section(\"Show\") {\n            Toggle(\"Hide ports under \\(minimumPort)\", isOn: hideSystem) { self.hideSystem = $0 }\n            Picker(\"Lowest port shown\", options: [\"1\", \"1024\", \"3000\"], selection: \"\\(minimumPort)\") { self.minimumPort = Int($0) ?? 1024 }\n        }\n        Section(\"About\") {\n            Text(\"Ports lists TCP ports that processes you can see are listening on. Stopping one sends it SIGTERM, so it can clean up; Force Quit sends SIGKILL. A process owned by another user (like a system service) can't be stopped from here.\").style(.caption)\n        }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"kill-port\", title: \"Kill Port\", subtitle: \"Stop whatever is listening on a port\", symbol: \"xmark.octagon\", keywords: [\"port\", \"kill\", \"lsof\", \"free\"]) { invocation in\n            guard let port = Int(invocation[\"port\"] ?? \"\") else { return .failure(\"Give a port number, like 3000\") }\n            await self.load()\n            let holders = self.listeners.filter { $0.port == port }\n            guard !holders.isEmpty else { return .message(\"Nothing is listening on \\(port)\") }\n            let force = invocation[\"signal\"] == \"KILL\"\n            var messages: [String] = []\n            for holder in Dictionary(grouping: holders, by: \\.pid).values.compactMap(\\.first) {\n                messages.append(try await self.stop(holder, force: force))\n            }\n            return .message(messages.joined(separator: \"; \"))\n        }\n        .argument(\"port\", placeholder: \"3000\")\n        .argument(\"signal\", required: false, default: \"TERM\", options: [\"TERM\", \"KILL\"])\n        Command(\"ports\", title: \"Listening Ports\", symbol: \"network\", keywords: [\"port\", \"lsof\", \"listen\", \"server\"])\n            .view(search: { _ in await self.load() }) { _ in\n                if let problem = self.problem {\n                    ContentUnavailableView(\"Can't list ports\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else {\n                    List(self.shown, searchable: true) { listener in self.row(listener) }\n                }\n            }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"listening\", description: \"TCP ports being listened on, as port, process, pid and address\") { _ in\n            await self.load()\n            if let problem = self.problem { return problem }\n            return self.listeners.map { \"\\($0.port)\\t\\($0.process)\\t\\($0.pid)\\t\\($0.address)\" }.joined(separator: \"\\n\")\n        }\n    }\n}\n",
  "sourceDigest" : "d2dd318cc02dbee1ade7aafc904be4ded1685a9eac4237bb3c866a38b7dba942"
}