diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4203ea0a..33b7ade6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -253,6 +253,321 @@ enum WorkspaceShortcutMapper { } } +struct CmuxCLIPathInstaller { + struct InstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let sourceURL: URL + } + + struct UninstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let removedExistingEntry: Bool + } + + enum InstallerError: LocalizedError { + case bundledCLIMissing(expectedPath: String) + case destinationParentNotDirectory(path: String) + case destinationIsDirectory(path: String) + case installVerificationFailed(path: String) + case uninstallVerificationFailed(path: String) + case privilegedCommandFailed(message: String) + + var errorDescription: String? { + switch self { + case .bundledCLIMissing(let expectedPath): + return "Bundled cmux CLI was not found at \(expectedPath)." + case .destinationParentNotDirectory(let path): + return "Expected \(path) to be a directory." + case .destinationIsDirectory(let path): + return "\(path) is a directory. Remove or rename it and try again." + case .installVerificationFailed(let path): + return "Installed symlink at \(path) did not point to the bundled cmux CLI." + case .uninstallVerificationFailed(let path): + return "Failed to remove \(path)." + case .privilegedCommandFailed(let message): + return "Administrator action failed: \(message)" + } + } + } + + typealias PrivilegedInstallHandler = (_ sourceURL: URL, _ destinationURL: URL) throws -> Void + typealias PrivilegedUninstallHandler = (_ destinationURL: URL) throws -> Void + + let fileManager: FileManager + let destinationURL: URL + private let bundledCLIURLProvider: () -> URL? + private let expectedBundledCLIPath: String + private let privilegedInstaller: PrivilegedInstallHandler + private let privilegedUninstaller: PrivilegedUninstallHandler + + init( + fileManager: FileManager = .default, + destinationURL: URL = URL(fileURLWithPath: "/usr/local/bin/cmux"), + bundledCLIURLProvider: @escaping () -> URL? = { + CmuxCLIPathInstaller.defaultBundledCLIURL() + }, + expectedBundledCLIPath: String = CmuxCLIPathInstaller.defaultBundledCLIExpectedPath(), + privilegedInstaller: PrivilegedInstallHandler? = nil, + privilegedUninstaller: PrivilegedUninstallHandler? = nil + ) { + self.fileManager = fileManager + self.destinationURL = destinationURL + self.bundledCLIURLProvider = bundledCLIURLProvider + self.expectedBundledCLIPath = expectedBundledCLIPath + self.privilegedInstaller = privilegedInstaller ?? Self.installWithAdministratorPrivileges(sourceURL:destinationURL:) + self.privilegedUninstaller = privilegedUninstaller ?? Self.uninstallWithAdministratorPrivileges(destinationURL:) + } + + var destinationPath: String { + destinationURL.path + } + + func install() throws -> InstallOutcome { + let sourceURL = try resolveBundledCLIURL() + do { + try installWithoutAdministratorPrivileges(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + try privilegedInstaller(sourceURL, destinationURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } + } + + func uninstall() throws -> UninstallOutcome { + do { + let removedExistingEntry = try uninstallWithoutAdministratorPrivileges() + return UninstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + let removedExistingEntry = destinationEntryExists() + try privilegedUninstaller(destinationURL) + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return UninstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } + } + + func isInstalled() -> Bool { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { return false } + guard let installedTargetURL = symlinkDestinationURL() else { return false } + return installedTargetURL == sourceURL + } + + private func resolveBundledCLIURL() throws -> URL { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { + throw InstallerError.bundledCLIMissing(expectedPath: expectedBundledCLIPath) + } + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path) + } + return sourceURL + } + + private func installWithoutAdministratorPrivileges(sourceURL: URL) throws { + try ensureDestinationParentDirectoryExists() + try ensureDestinationIsNotDirectory() + if destinationEntryExists() { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + } + + @discardableResult + private func uninstallWithoutAdministratorPrivileges() throws -> Bool { + try ensureDestinationIsNotDirectory() + let existed = destinationEntryExists() + if existed { + try fileManager.removeItem(at: destinationURL) + } + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return existed + } + + /// Check if the destination path has any filesystem entry (including dangling symlinks). + /// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false. + private func destinationEntryExists() -> Bool { + (try? fileManager.attributesOfItem(atPath: destinationURL.path)) != nil + } + + private func verifyInstalledSymlinkTarget(sourceURL: URL) throws { + guard let installedTargetURL = symlinkDestinationURL(), + installedTargetURL == sourceURL.standardizedFileURL else { + throw InstallerError.installVerificationFailed(path: destinationURL.path) + } + } + + private func symlinkDestinationURL() -> URL? { + guard fileManager.fileExists(atPath: destinationURL.path) else { return nil } + guard let destinationPath = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else { + return nil + } + return URL( + fileURLWithPath: destinationPath, + relativeTo: destinationURL.deletingLastPathComponent() + ).standardizedFileURL + } + + private func ensureDestinationParentDirectoryExists() throws { + let parentURL = destinationURL.deletingLastPathComponent() + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: parentURL.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw InstallerError.destinationParentNotDirectory(path: parentURL.path) + } + return + } + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) + } + + private func ensureDestinationIsNotDirectory() throws { + guard let values = try resourceValuesIfFileExists( + at: destinationURL, + keys: [.isDirectoryKey, .isSymbolicLinkKey] + ) else { + return + } + + if values.isDirectory == true, values.isSymbolicLink != true { + throw InstallerError.destinationIsDirectory(path: destinationURL.path) + } + } + + private func resourceValuesIfFileExists( + at url: URL, + keys: Set + ) throws -> URLResourceValues? { + do { + return try url.resourceValues(forKeys: keys) + } catch { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError { + return nil + } + if nsError.domain == NSPOSIXErrorDomain, + POSIXErrorCode(rawValue: Int32(nsError.code)) == .ENOENT { + return nil + } + throw error + } + } + + private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? { + bundle.resourceURL?.appendingPathComponent("bin/cmux", isDirectory: false) + } + + private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String { + bundle.bundleURL + .appendingPathComponent("Contents/Resources/bin/cmux", isDirectory: false) + .path + } + + private static func installWithAdministratorPrivileges(sourceURL: URL, destinationURL: URL) throws { + let destinationPath = destinationURL.path + let parentPath = destinationURL.deletingLastPathComponent().path + let command = "/bin/mkdir -p \(shellQuoted(parentPath)) && " + + "/bin/rm -f \(shellQuoted(destinationPath)) && " + + "/bin/ln -s \(shellQuoted(sourceURL.path)) \(shellQuoted(destinationPath))" + try runPrivilegedShellCommand(command) + } + + private static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws { + let command = "/bin/rm -f \(shellQuoted(destinationURL.path))" + try runPrivilegedShellCommand(command) + } + + private static func runPrivilegedShellCommand(_ command: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = [ + "-e", "on run argv", + "-e", "do shell script (item 1 of argv) with administrator privileges", + "-e", "end run", + command + ] + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let stderrText = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stdoutText = String( + data: stdout.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let details = stderrText.isEmpty ? stdoutText : stderrText + let message = details.isEmpty + ? "osascript exited with status \(process.terminationStatus)." + : details + throw InstallerError.privilegedCommandFailed(message: message) + } + } + + private static func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func isPermissionDenied(_ error: Error) -> Bool { + isPermissionDenied(error as NSError) + } + + private static func isPermissionDenied(_ error: NSError) -> Bool { + if error.domain == NSPOSIXErrorDomain, + let code = POSIXErrorCode(rawValue: Int32(error.code)), + code == .EACCES || code == .EPERM || code == .EROFS { + return true + } + + if error.domain == NSCocoaErrorDomain { + switch error.code { + case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError: + return true + default: + break + } + } + + if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { + return isPermissionDenied(underlying) + } + + return false + } +} + private extension NSScreen { var cmuxDisplayID: UInt32? { let key = NSDeviceDescriptionKey("NSScreenNumber") @@ -3467,6 +3782,79 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updateController.attemptUpdate() } + func isCmuxCLIInstalledInPATH() -> Bool { + CmuxCLIPathInstaller().isInstalled() + } + + @objc func installCmuxCLIInPath(_ sender: Any?) { + let installer = CmuxCLIPathInstaller() + do { + let outcome = try installer.install() + var informativeText = """ + Created symlink: + + \(outcome.destinationURL.path) -> \(outcome.sourceURL.path) + """ + if outcome.usedAdministratorPrivileges { + informativeText += "\n\nAdministrator privileges were required to write to /usr/local/bin." + } + presentCLIPathAlert( + title: "cmux CLI Installed", + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: "Couldn't Install cmux CLI", + informativeText: error.localizedDescription, + style: .warning + ) + } + } + + @objc func uninstallCmuxCLIInPath(_ sender: Any?) { + let installer = CmuxCLIPathInstaller() + do { + let outcome = try installer.uninstall() + let prefix = outcome.removedExistingEntry + ? "Removed \(outcome.destinationURL.path)." + : "No cmux CLI symlink was found at \(outcome.destinationURL.path)." + var informativeText = prefix + if outcome.usedAdministratorPrivileges { + informativeText += "\n\nAdministrator privileges were required to modify /usr/local/bin." + } + presentCLIPathAlert( + title: "cmux CLI Uninstalled", + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: "Couldn't Uninstall cmux CLI", + informativeText: error.localizedDescription, + style: .warning + ) + } + } + + private func presentCLIPathAlert( + title: String, + informativeText: String, + style: NSAlert.Style + ) { + let alert = NSAlert() + alert.alertStyle = style + alert.messageText = title + alert.informativeText = informativeText + alert.addButton(withTitle: "OK") + + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + _ = alert.runModal() + } + } + @objc func restartSocketListener(_ sender: Any?) { guard tabManager != nil else { NSSound.beep() diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 60baf04b..1aa861c4 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3504,6 +3504,24 @@ struct ContentView: View { keywords: ["create", "new", "window"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.installCLI", + title: constant("Shell Command: Install 'cmux' in PATH"), + subtitle: constant("CLI"), + keywords: ["install", "cli", "path", "shell", "command", "symlink"], + when: { _ in !(AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.uninstallCLI", + title: constant("Shell Command: Uninstall 'cmux' from PATH"), + subtitle: constant("CLI"), + keywords: ["uninstall", "remove", "cli", "path", "shell", "command", "symlink"], + when: { _ in AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false } + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.newTerminalTab", @@ -4021,6 +4039,12 @@ struct ContentView: View { registry.register(commandId: "palette.newWindow") { AppDelegate.shared?.openNewMainWindow(nil) } + registry.register(commandId: "palette.installCLI") { + AppDelegate.shared?.installCmuxCLIInPath(nil) + } + registry.register(commandId: "palette.uninstallCLI") { + AppDelegate.shared?.uninstallCmuxCLIInPath(nil) + } registry.register(commandId: "palette.newTerminalTab") { tabManager.newSurface() } diff --git a/cmuxTests/SocketControlPasswordStoreTests.swift b/cmuxTests/SocketControlPasswordStoreTests.swift index fca99fe1..ea45e661 100644 --- a/cmuxTests/SocketControlPasswordStoreTests.swift +++ b/cmuxTests/SocketControlPasswordStoreTests.swift @@ -240,3 +240,94 @@ final class SocketControlPasswordStoreTests: XCTestCase { XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret") } } + +final class CmuxCLIPathInstallerTests: XCTestCase { + func testInstallAndUninstallRoundTripWithoutAdministratorPrivileges() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let bundledCLIURL = root + .appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false) + try fileManager.createDirectory( + at: bundledCLIURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8) + + let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false) + + var privilegedInstallCallCount = 0 + var privilegedUninstallCallCount = 0 + let installer = CmuxCLIPathInstaller( + fileManager: fileManager, + destinationURL: destinationURL, + bundledCLIURLProvider: { bundledCLIURL }, + expectedBundledCLIPath: bundledCLIURL.path, + privilegedInstaller: { _, _ in privilegedInstallCallCount += 1 }, + privilegedUninstaller: { _ in privilegedUninstallCallCount += 1 } + ) + + let installOutcome = try installer.install() + XCTAssertFalse(installOutcome.usedAdministratorPrivileges) + XCTAssertEqual(privilegedInstallCallCount, 0) + XCTAssertTrue(installer.isInstalled()) + XCTAssertEqual( + try fileManager.destinationOfSymbolicLink(atPath: destinationURL.path), + bundledCLIURL.path + ) + + let uninstallOutcome = try installer.uninstall() + XCTAssertFalse(uninstallOutcome.usedAdministratorPrivileges) + XCTAssertTrue(uninstallOutcome.removedExistingEntry) + XCTAssertEqual(privilegedUninstallCallCount, 0) + XCTAssertFalse(fileManager.fileExists(atPath: destinationURL.path)) + XCTAssertFalse(installer.isInstalled()) + } + + func testInstallFallsBackToAdministratorFlowWhenDestinationIsNotWritable() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let bundledCLIURL = root + .appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false) + try fileManager.createDirectory( + at: bundledCLIURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8) + + let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false) + let destinationDir = destinationURL.deletingLastPathComponent() + try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true) + try fileManager.setAttributes([.posixPermissions: 0o555], ofItemAtPath: destinationDir.path) + defer { + try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path) + } + + var privilegedInstallCallCount = 0 + let installer = CmuxCLIPathInstaller( + fileManager: fileManager, + destinationURL: destinationURL, + bundledCLIURLProvider: { bundledCLIURL }, + expectedBundledCLIPath: bundledCLIURL.path, + privilegedInstaller: { sourceURL, privilegedDestinationURL in + privilegedInstallCallCount += 1 + XCTAssertEqual(sourceURL.standardizedFileURL, bundledCLIURL.standardizedFileURL) + XCTAssertEqual(privilegedDestinationURL.standardizedFileURL, destinationURL.standardizedFileURL) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path) + try fileManager.createSymbolicLink(at: privilegedDestinationURL, withDestinationURL: sourceURL) + } + ) + + let installOutcome = try installer.install() + XCTAssertTrue(installOutcome.usedAdministratorPrivileges) + XCTAssertEqual(privilegedInstallCallCount, 1) + XCTAssertTrue(installer.isInstalled()) + } +}