diff --git a/ListenBar/Sources/Services/PortScanning/PortScannerService.swift b/ListenBar/Sources/Services/PortScanning/PortScannerService.swift index 3d8202d..ea61039 100644 --- a/ListenBar/Sources/Services/PortScanning/PortScannerService.swift +++ b/ListenBar/Sources/Services/PortScanning/PortScannerService.swift @@ -1,3 +1,5 @@ +import Darwin +import Dispatch import Foundation enum PortScannerError: LocalizedError, Equatable { @@ -21,7 +23,19 @@ enum PortScannerError: LocalizedError, Equatable { } } +struct PortScannerProcessResult: Equatable, Sendable { + let terminationStatus: Int32 + let standardOutput: Data + let standardError: Data +} + enum PortScannerService { + private static let processIOQueue = DispatchQueue( + label: "top.ygsgdbd.ListenBar.PortScannerService.IO", + qos: .userInitiated, + attributes: .concurrent, + ) + static let lsofPath = "/usr/sbin/lsof" static let lsofArguments = [ "-nP", @@ -36,39 +50,116 @@ enum PortScannerService { ] static func scanListeningPorts() async throws -> [PortEntry] { - try await Task.detached(priority: .userInitiated) { - let process = Process() - process.executableURL = URL(fileURLWithPath: lsofPath) - process.arguments = lsofArguments + let result = try await executeProcess( + executableURL: URL(fileURLWithPath: lsofPath), + arguments: lsofArguments, + ) + return try interpretLsofResult(result) + } + + static func executeProcess( + executableURL: URL, + arguments: [String], + ) async throws -> PortScannerProcessResult { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + + let standardOutput = Pipe() + let standardError = Pipe() + process.standardOutput = standardOutput + process.standardError = standardError + + let outputDescriptor = try duplicateDescriptor( + standardOutput.fileHandleForReading.fileDescriptor, + ) + let errorDescriptor: Int32 + do { + errorDescriptor = try duplicateDescriptor( + standardError.fileHandleForReading.fileDescriptor, + ) + } catch { + Darwin.close(outputDescriptor) + throw error + } - let standardOutput = Pipe() - let standardError = Pipe() - process.standardOutput = standardOutput - process.standardError = standardError + async let outputData = readData(from: outputDescriptor) + async let errorData = readData(from: errorDescriptor) - try process.run() - process.waitUntilExit() + do { + let terminationStatus = try await runAndWaitForTermination(process) + let (standardOutputData, standardErrorData) = try await (outputData, errorData) + return PortScannerProcessResult( + terminationStatus: terminationStatus, + standardOutput: standardOutputData, + standardError: standardErrorData, + ) + } catch { + try? standardOutput.fileHandleForWriting.close() + try? standardError.fileHandleForWriting.close() + _ = try? await (outputData, errorData) + throw error + } + } - let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile() - let errorData = standardError.fileHandleForReading.readDataToEndOfFile() + static func interpretLsofResult(_ result: PortScannerProcessResult) throws -> [PortEntry] { + guard result.terminationStatus == 0 else { + let message = String(data: result.standardError, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + throw PortScannerError.lsofFailed( + status: result.terminationStatus, + message: message, + ) + } - guard let output = String(data: outputData, encoding: .utf8) else { - throw PortScannerError.lsofOutputUnreadable + guard let output = String(data: result.standardOutput, encoding: .utf8) else { + throw PortScannerError.lsofOutputUnreadable + } + + return parseLsofFieldOutput(output) + } + + private static func duplicateDescriptor(_ descriptor: Int32) throws -> Int32 { + let duplicate = Darwin.dup(descriptor) + guard duplicate >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + return duplicate + } + + private static func readData(from descriptor: Int32) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + let state = DispatchIOReadState(continuation: continuation) + let channel = DispatchIO( + type: .stream, + fileDescriptor: descriptor, + queue: processIOQueue, + ) { _ in + Darwin.close(descriptor) + } + channel.setLimit(lowWater: 64 * 1024) + channel.read(offset: 0, length: Int.max, queue: processIOQueue) { done, chunk, error in + if done { + channel.close() + } + state.receive(chunk: chunk, done: done, error: error) } + } + } - let ports = parseLsofFieldOutput(output) - if ports.isEmpty, process.terminationStatus != 0 { - let message = String(data: errorData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - throw PortScannerError.lsofFailed( - status: process.terminationStatus, - message: message, - ) + private static func runAndWaitForTermination(_ process: Process) async throws -> Int32 { + try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { terminatedProcess in + continuation.resume(returning: terminatedProcess.terminationStatus) } - return ports + do { + try process.run() + } catch { + process.terminationHandler = nil + continuation.resume(throwing: error) + } } - .value } static func parseLsofFieldOutput(_ output: String) -> [PortEntry] { @@ -186,3 +277,28 @@ private struct FileRecord { var networkProtocol: NetworkProtocol? var name: String? } + +private final class DispatchIOReadState: @unchecked Sendable { + private let continuation: CheckedContinuation + private var data = Data() + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + // DispatchIO guarantees that handlers for one read operation are not reentrant. + func receive(chunk: DispatchData?, done: Bool, error: Int32) { + if let chunk { + chunk.enumerateBytes { buffer, _, _ in + data.append(contentsOf: buffer) + } + } + + guard done else { return } + if error == 0 { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: POSIXError(POSIXErrorCode(rawValue: error) ?? .EIO)) + } + } +} diff --git a/ListenBar/Sources/Services/System/LaunchAtLoginService.swift b/ListenBar/Sources/Services/System/LaunchAtLoginService.swift index 181b63c..8a88c5a 100644 --- a/ListenBar/Sources/Services/System/LaunchAtLoginService.swift +++ b/ListenBar/Sources/Services/System/LaunchAtLoginService.swift @@ -10,11 +10,13 @@ struct LaunchAtLoginServiceEnvironment { var runLaunchctl: ([String]) throws -> Void var userID: () -> uid_t var fileManager: FileManager + var replaceItemAt: (URL, URL) throws -> Void } enum LaunchAtLoginService { private static let launchAgentIdentifier = "top.ygsgdbd.ListenBar" private static let launchAgentPlistName = "\(launchAgentIdentifier).plist" + private static let launchAgentStagingDirectoryName = ".\(launchAgentIdentifier).staging" private static var liveEnvironment: LaunchAtLoginServiceEnvironment { LaunchAtLoginServiceEnvironment( @@ -28,6 +30,12 @@ enum LaunchAtLoginService { runLaunchctl: runLaunchctl, userID: { getuid() }, fileManager: .default, + replaceItemAt: { originalItemURL, newItemURL in + _ = try FileManager.default.replaceItemAt( + originalItemURL, + withItemAt: newItemURL, + ) + }, ) } @@ -166,6 +174,13 @@ private extension LaunchAtLoginService { at: environment.plistURL.deletingLastPathComponent(), withIntermediateDirectories: true, ) + let stagingDirectoryURL = environment.plistURL.deletingLastPathComponent() + .appendingPathComponent(launchAgentStagingDirectoryName, isDirectory: true) + let stagingPlistURL = stagingDirectoryURL.appendingPathComponent(launchAgentPlistName) + try environment.fileManager.createDirectory( + at: stagingDirectoryURL, + withIntermediateDirectories: true, + ) let plist: [String: Any] = [ "Label": launchAgentIdentifier, @@ -177,19 +192,92 @@ private extension LaunchAtLoginService { format: .xml, options: 0, ) - try data.write(to: environment.plistURL, options: .atomic) + try data.write(to: stagingPlistURL, options: .atomic) let domain = "gui/\(environment.userID())" - try? environment.runLaunchctl(["bootout", domain, environment.plistURL.path]) - try environment.runLaunchctl(["bootstrap", domain, environment.plistURL.path]) + let serviceTarget = "\(domain)/\(launchAgentIdentifier)" + try? environment.runLaunchctl(["bootout", serviceTarget]) + do { + try environment.runLaunchctl(["bootstrap", domain, stagingPlistURL.path]) + try commitFallbackLaunchAgent( + stagingPlistURL: stagingPlistURL, + environment: environment, + ) + } catch { + cleanUpFailedFallbackInstallation( + stagingDirectoryURL: stagingDirectoryURL, + serviceTarget: serviceTarget, + environment: environment, + ) + throw error + } + + removeStagingDirectory( + stagingDirectoryURL, + fileManager: environment.fileManager, + ) } static func removeFallbackLaunchAgent(environment: LaunchAtLoginServiceEnvironment) { - let domain = "gui/\(environment.userID())" - try? environment.runLaunchctl(["bootout", domain, environment.plistURL.path]) + let serviceTarget = "gui/\(environment.userID())/\(launchAgentIdentifier)" + try? environment.runLaunchctl(["bootout", serviceTarget]) try? environment.fileManager.removeItem(at: environment.plistURL) } + static func commitFallbackLaunchAgent( + stagingPlistURL: URL, + environment: LaunchAtLoginServiceEnvironment, + ) throws { + if environment.fileManager.fileExists(atPath: environment.plistURL.path) { + try environment.replaceItemAt( + environment.plistURL, + stagingPlistURL, + ) + } else { + try environment.fileManager.moveItem( + at: stagingPlistURL, + to: environment.plistURL, + ) + } + } + + static func cleanUpFailedFallbackInstallation( + stagingDirectoryURL: URL, + serviceTarget: String, + environment: LaunchAtLoginServiceEnvironment, + ) { + do { + try environment.runLaunchctl(["bootout", serviceTarget]) + } catch { + print("Failed to clean up ListenBar LaunchAgent service: \(error.localizedDescription)") + } + + removeStagingDirectory( + stagingDirectoryURL, + fileManager: environment.fileManager, + ) + + if environment.fileManager.fileExists(atPath: environment.plistURL.path) { + do { + try environment.fileManager.removeItem(at: environment.plistURL) + } catch { + print("Failed to remove ListenBar LaunchAgent plist after rollback: \(error.localizedDescription)") + } + } + } + + static func removeStagingDirectory(_ url: URL, fileManager: FileManager) { + guard fileManager.fileExists(atPath: url.path) else { + return + } + + do { + try fileManager.removeItem(at: url) + } catch { + print("Failed to remove ListenBar LaunchAgent staging files: \(error.localizedDescription)") + } + } + static func runLaunchctl(arguments: [String]) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/launchctl") diff --git a/ListenBarTests/LaunchAtLoginServiceTests.swift b/ListenBarTests/LaunchAtLoginServiceTests.swift index 99256a2..b7d529c 100644 --- a/ListenBarTests/LaunchAtLoginServiceTests.swift +++ b/ListenBarTests/LaunchAtLoginServiceTests.swift @@ -44,7 +44,7 @@ final class LaunchAtLoginServiceTests: XCTestCase { ) } - func testSetLaunchAtLoginFallsBackWhenServiceManagementRequiresApproval() throws { + func testSetLaunchAtLoginCommitsFallbackWhenServiceManagementRequiresApproval() throws { let fixture = try Fixture() defer { fixture.cleanUp() } var launchctlCalls: [[String]] = [] @@ -61,11 +61,136 @@ final class LaunchAtLoginServiceTests: XCTestCase { XCTAssertEqual( launchctlCalls, [ - ["bootout", "gui/501", fixture.plistURL.path], - ["bootstrap", "gui/501", fixture.plistURL.path], + ["bootout", fixture.serviceTarget], + ["bootstrap", "gui/501", fixture.stagingPlistURL.path], ], ) XCTAssertEqual(try fixture.programArguments(), [fixture.executableURL.path]) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.stagingDirectoryURL.path)) + } + + func testSetLaunchAtLoginRollsBackWhenBootstrapAndBootoutFail() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + var launchctlCalls: [[String]] = [] + + let status = LaunchAtLoginService.setLaunchAtLogin( + true, + environment: fixture.environment( + serviceManagementStatus: { .requiresApproval }, + runLaunchctl: { arguments in + launchctlCalls.append(arguments) + if arguments.first == "bootstrap" || arguments.first == "bootout" { + throw NSError(domain: "test", code: 1) + } + }, + ), + ) + + XCTAssertEqual(status, .requiresApproval) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.plistURL.path)) + XCTAssertEqual( + launchctlCalls, + [ + ["bootout", fixture.serviceTarget], + ["bootstrap", "gui/501", fixture.stagingPlistURL.path], + ["bootout", fixture.serviceTarget], + ], + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.stagingDirectoryURL.path)) + } + + func testSetLaunchAtLoginIgnoresResidualStagingWhenBootstrapAndDeletionFail() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let fileManager = TestFileManager() + fileManager.removeItemErrorURL = fixture.stagingDirectoryURL + var launchctlCalls: [[String]] = [] + + let environment = fixture.environment( + serviceManagementStatus: { .requiresApproval }, + runLaunchctl: { arguments in + launchctlCalls.append(arguments) + if arguments.first == "bootstrap" { + throw NSError(domain: "bootstrap", code: 1) + } + }, + fileManager: fileManager, + ) + let status = LaunchAtLoginService.setLaunchAtLogin(true, environment: environment) + + XCTAssertEqual(status, .requiresApproval) + XCTAssertEqual(LaunchAtLoginService.status(environment: environment), .requiresApproval) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.plistURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.stagingPlistURL.path)) + XCTAssertEqual( + launchctlCalls, + [ + ["bootout", fixture.serviceTarget], + ["bootstrap", "gui/501", fixture.stagingPlistURL.path], + ["bootout", fixture.serviceTarget], + ], + ) + } + + func testSetLaunchAtLoginRollsBackWhenCommittingStagingFails() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let fileManager = TestFileManager() + fileManager.moveItemError = NSError(domain: "move", code: 1) + var launchctlCalls: [[String]] = [] + + let status = LaunchAtLoginService.setLaunchAtLogin( + true, + environment: fixture.environment( + serviceManagementStatus: { .requiresApproval }, + runLaunchctl: { launchctlCalls.append($0) }, + fileManager: fileManager, + ), + ) + + XCTAssertEqual(status, .requiresApproval) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.plistURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.stagingDirectoryURL.path)) + XCTAssertEqual( + launchctlCalls, + [ + ["bootout", fixture.serviceTarget], + ["bootstrap", "gui/501", fixture.stagingPlistURL.path], + ["bootout", fixture.serviceTarget], + ], + ) + } + + func testSetLaunchAtLoginRollsBackWhenReplacingExistingPlistFails() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.writeLaunchAgent(executablePath: fixture.executableURL.path) + let fileManager = TestFileManager() + var launchctlCalls: [[String]] = [] + + let environment = fixture.environment( + serviceManagementStatus: { .requiresApproval }, + runLaunchctl: { launchctlCalls.append($0) }, + fileManager: fileManager, + replaceItemAt: { _, _ in + throw NSError(domain: "replace", code: 1) + }, + ) + let status = LaunchAtLoginService.setLaunchAtLogin(true, environment: environment) + + XCTAssertEqual(status, .requiresApproval) + XCTAssertEqual(LaunchAtLoginService.status(environment: environment), .requiresApproval) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.plistURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.stagingDirectoryURL.path)) + XCTAssertEqual( + launchctlCalls, + [ + ["bootout", fixture.serviceTarget], + ["bootstrap", "gui/501", fixture.stagingPlistURL.path], + ["bootout", fixture.serviceTarget], + ], + ) } func testSetLaunchAtLoginFallsBackWhenRegistrationThrows() throws { @@ -99,7 +224,7 @@ final class LaunchAtLoginServiceTests: XCTestCase { ) XCTAssertEqual(status, .enabled) - XCTAssertEqual(launchctlCalls, [["bootout", "gui/501", fixture.plistURL.path]]) + XCTAssertEqual(launchctlCalls, [["bootout", fixture.serviceTarget]]) XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.plistURL.path)) } @@ -121,7 +246,7 @@ final class LaunchAtLoginServiceTests: XCTestCase { ) XCTAssertEqual(status, .enabled) - XCTAssertEqual(launchctlCalls, [["bootout", "gui/501", fixture.plistURL.path]]) + XCTAssertEqual(launchctlCalls, [["bootout", fixture.serviceTarget]]) XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.plistURL.path)) } @@ -142,7 +267,7 @@ final class LaunchAtLoginServiceTests: XCTestCase { XCTAssertEqual(status, .disabled) XCTAssertTrue(didUnregister) - XCTAssertEqual(launchctlCalls, [["bootout", "gui/501", fixture.plistURL.path]]) + XCTAssertEqual(launchctlCalls, [["bootout", fixture.serviceTarget]]) XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.plistURL.path)) } } @@ -150,12 +275,19 @@ final class LaunchAtLoginServiceTests: XCTestCase { private struct Fixture { let directoryURL: URL let plistURL: URL + let stagingDirectoryURL: URL + let stagingPlistURL: URL let executableURL = URL(fileURLWithPath: "/Applications/ListenBar.app/Contents/MacOS/ListenBar") + let serviceTarget = "gui/501/top.ygsgdbd.ListenBar" init() throws { directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent("ListenBarTests-\(UUID().uuidString)", isDirectory: true) plistURL = directoryURL.appendingPathComponent("top.ygsgdbd.ListenBar.plist") + stagingDirectoryURL = directoryURL + .appendingPathComponent(".top.ygsgdbd.ListenBar.staging", isDirectory: true) + stagingPlistURL = stagingDirectoryURL + .appendingPathComponent("top.ygsgdbd.ListenBar.plist") try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) } @@ -164,6 +296,8 @@ private struct Fixture { registerMainApp: @escaping () throws -> Void = {}, unregisterMainApp: @escaping () throws -> Void = {}, runLaunchctl: @escaping ([String]) throws -> Void = { _ in }, + fileManager: FileManager = .default, + replaceItemAt: ((URL, URL) throws -> Void)? = nil, ) -> LaunchAtLoginServiceEnvironment { LaunchAtLoginServiceEnvironment( serviceManagementStatus: serviceManagementStatus, @@ -173,7 +307,13 @@ private struct Fixture { executableURL: { executableURL }, runLaunchctl: runLaunchctl, userID: { 501 }, - fileManager: .default, + fileManager: fileManager, + replaceItemAt: replaceItemAt ?? { originalItemURL, newItemURL in + _ = try fileManager.replaceItemAt( + originalItemURL, + withItemAt: newItemURL, + ) + }, ) } @@ -204,3 +344,22 @@ private struct Fixture { try? FileManager.default.removeItem(at: directoryURL) } } + +private final class TestFileManager: FileManager, @unchecked Sendable { + var moveItemError: Error? + var removeItemErrorURL: URL? + + override func moveItem(at srcURL: URL, to dstURL: URL) throws { + if let moveItemError { + throw moveItemError + } + try super.moveItem(at: srcURL, to: dstURL) + } + + override func removeItem(at URL: URL) throws { + if URL.standardizedFileURL == removeItemErrorURL?.standardizedFileURL { + throw NSError(domain: "remove", code: 1) + } + try super.removeItem(at: URL) + } +} diff --git a/ListenBarTests/PortScannerParserTests.swift b/ListenBarTests/PortScannerParserTests.swift index 0a8e6ce..f468542 100644 --- a/ListenBarTests/PortScannerParserTests.swift +++ b/ListenBarTests/PortScannerParserTests.swift @@ -2,6 +2,119 @@ import XCTest final class PortScannerParserTests: XCTestCase { + func testExecutesProcessWhileDrainingLargeStandardOutputAndError() async throws { + let byteCount = 1_048_576 + let result = try await PortScannerService.executeProcess( + executableURL: URL(fileURLWithPath: "/usr/bin/perl"), + arguments: [ + "-e", + #"$SIG{ALRM} = sub { die "alarm\n" }; alarm 3; print STDOUT "o" x 1048576; print STDERR "e" x 1048576; alarm 0;"#, + ], + ) + + XCTAssertEqual(result.terminationStatus, 0) + XCTAssertEqual(result.standardOutput, Data(repeating: Character("o").asciiValue!, count: byteCount)) + XCTAssertEqual(result.standardError, Data(repeating: Character("e").asciiValue!, count: byteCount)) + } + + func testExecutesManyProcessesWhileDrainingLargeStandardOutputAndError() async throws { + executionTimeAllowance = 15 + let processCount = ProcessInfo.processInfo.activeProcessorCount + let byteCount = 1_048_576 + + let results = try await withThrowingTaskGroup(of: PortScannerProcessResult.self) { group in + for _ in 0 ..< processCount { + group.addTask { + try await PortScannerService.executeProcess( + executableURL: URL(fileURLWithPath: "/usr/bin/perl"), + arguments: [ + "-e", + #"$SIG{ALRM} = sub { die "alarm\n" }; alarm 12; print STDOUT "o" x 1048576; print STDERR "e" x 1048576; alarm 0;"#, + ], + ) + } + } + + var results: [PortScannerProcessResult] = [] + for try await result in group { + results.append(result) + } + return results + } + + XCTAssertEqual(results.count, processCount) + for result in results { + XCTAssertEqual(result.terminationStatus, 0) + XCTAssertEqual(result.standardOutput.count, byteCount) + XCTAssertEqual(result.standardError.count, byteCount) + } + } + + func testExecuteProcessReturnsWhenProcessFailsToRun() async { + executionTimeAllowance = 3 + + do { + _ = try await PortScannerService.executeProcess( + executableURL: URL(fileURLWithPath: "/path/that/does/not/exist"), + arguments: [], + ) + XCTFail("Expected process.run() to fail") + } catch { + XCTAssertFalse(error.localizedDescription.isEmpty) + } + } + + func testRejectsParseableOutputWhenLsofExitsNonzero() { + let result = PortScannerProcessResult( + terminationStatus: 1, + standardOutput: Data(Self.parseableLsofOutput.utf8), + standardError: Data("permission denied\n".utf8), + ) + + XCTAssertThrowsError(try PortScannerService.interpretLsofResult(result)) { error in + XCTAssertEqual( + error as? PortScannerError, + .lsofFailed(status: 1, message: "permission denied"), + ) + } + } + + func testInterpretsParseableOutputWhenLsofExitsSuccessfully() throws { + let result = PortScannerProcessResult( + terminationStatus: 0, + standardOutput: Data(Self.parseableLsofOutput.utf8), + standardError: Data(), + ) + + XCTAssertEqual( + try PortScannerService.interpretLsofResult(result), + [ + PortEntry( + networkProtocol: .tcp, + address: "*", + port: 8081, + pid: 24106, + command: "node", + user: "501", + ), + ], + ) + } + + func testUsesStatusFallbackWhenLsofExitsNonzeroWithoutStandardError() { + let result = PortScannerProcessResult( + terminationStatus: 9, + standardOutput: Data(), + standardError: Data(), + ) + + XCTAssertThrowsError(try PortScannerService.interpretLsofResult(result)) { error in + let scannerError = error as? PortScannerError + XCTAssertEqual(scannerError, .lsofFailed(status: 9, message: "")) + XCTAssertEqual(scannerError?.errorDescription?.contains("9"), true) + } + } + func testParsesTcpAndLoopbackPorts() { let ports = PortScannerService.parseLsofFieldOutput( """ @@ -243,4 +356,13 @@ final class PortScannerParserTests: XCTestCase { XCTAssertEqual(ports.map(\.address), ["0.0.0.0", "[::]"]) XCTAssertEqual(ports.map(\.port), [3000, 3001]) } + + private static let parseableLsofOutput = """ + p24106 + cnode + u501 + f31 + PTCP + n*:8081 + """ } diff --git a/ROADMAP.md b/ROADMAP.md index c885a42..ec103e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ListenBar Roadmap -This file tracks feature ideas that are intentionally deferred from the current P1 hardening pass. +This file tracks current product priorities, deferred ideas, and features that are not planned for the current direction. ## Menu Bar Status Count @@ -10,28 +10,35 @@ This file tracks feature ideas that are intentionally deferred from the current ## Hidden Process Configuration -- Candidate: hide system processes, hide specific app/process groups, or keep an ignore list. -- Deferred because it needs persistent preferences and clear hide identity semantics. -- Prefer bundle identifier or process group ID over PID-only hiding. +- Implemented: persistently ignore individual app or executable groups using bundle identifiers or absolute executable paths. +- The menu supports restoring individual ignored items or clearing the full ignore list. +- Deferred extension: optional category filters such as hiding system processes; define category and precedence semantics before implementation. ## Search -- Candidate: filter by port, app/process name, PID, source, command line, or path. -- Deferred to avoid changing the current `MenuBarExtra` menu interaction model. -- Revisit with either lightweight in-memory filtering or a richer popover-style UI. +- Not planned for the current menu-based product direction. +- `MenuBarExtra` cannot provide the intended inline search-field experience without moving to a richer popover or window. +- Watched ports and port change history should reduce the need to search the full listener list. ## Watched Ports -- Candidate: watch `protocol + port`, pin watched ports, and show `free` or owner status. -- Deferred because it needs persistent configuration and clear conflict semantics. -- This should be the foundation for future notifications. +- Current priority: watch and persist `protocol + port` entries. +- Show whether each watched port is free or occupied and identify its current owner when available. +- Define stable identity and conflict semantics before adding notifications or menu bar status counts. ## Homebrew And LaunchAgent Attribution -- Candidate: parse LaunchAgent/LaunchDaemon plists, `launchctl`, and optional `brew services` output. -- Deferred because current source inference is heuristic and this needs evidence-based confidence labels. +- Recorded for a later pass: parse LaunchAgent/LaunchDaemon plists, `launchctl`, and optional `brew services` output. +- The goal is more reliable service-source attribution than the current heuristic inference. +- Deferred because it needs evidence-based confidence labels. - Avoid making strong claims unless a label, PID, or plist match is available. +## LaunchAtLogin Rollback Hardening + +- Deferred: the current failed-install rollback removes the committed fallback plist to avoid reporting login launch as enabled when no service is loaded. +- Preserve any valid fallback plist that existed before installation and restore it if the replacement attempt fails. +- Re-bootstrap the previous service when needed so one failed enable attempt does not permanently discard a working login configuration. + ## HTTP Title Probe - Candidate: fetch localhost HTTP title for TCP ports. @@ -40,8 +47,10 @@ This file tracks feature ideas that are intentionally deferred from the current ## History -- Candidate: keep snapshot history and show which process previously occupied a port. -- Deferred because it needs retention, persistence, and privacy policy. +- Current priority: record changes for watched ports. +- Track transitions such as `free -> occupied`, `occupied -> free`, and owner changes. +- Keep the first version focused on watched ports instead of retaining every listener snapshot. +- Retention duration, persistence, and privacy rules still need to be defined. ## Notifications @@ -50,9 +59,21 @@ This file tracks feature ideas that are intentionally deferred from the current ## Diagnostic Report -- Candidate: copy a diagnostic report containing grouped ports, commands, errors, and environment hints. -- Deferred because exported data needs redaction rules and explicit user intent. -- A text-only report should avoid raw command lines unless the user chooses raw export. +- Low priority enhancement: extend the existing Copy Full Information action into a fuller diagnostic report. +- The current export already includes grouped listener details, sources, URLs, and executable paths. +- Future additions may include scan errors, app/version details, and environment hints. +- Command lines require redaction rules, and raw export must remain an explicit user choice. + +## Manual Refresh + +- Low priority: allow a one-time refresh without changing the saved automatic refresh mode. +- The existing menu-open and interval-based refresh modes cover the primary workflow. + +## Automation Integrations + +- Low priority: expose selected ListenBar actions through App Intents. +- A future Raycast plugin may consume those actions or provide a dedicated ListenBar integration. +- Define the safe read-only action surface before exposing process termination or other destructive operations. ## Dev Ports Mode diff --git a/justfile b/justfile index f4ae4fe..4e5223b 100644 --- a/justfile +++ b/justfile @@ -90,6 +90,8 @@ test: check-tuist -project ListenBar.xcodeproj \ -scheme ListenBar \ -destination 'platform=macOS' \ + -test-timeouts-enabled YES \ + -maximum-test-execution-time-allowance 15 \ -testLanguage zh-Hans \ -skipPackagePluginValidation \ -skipMacroValidation \