diff --git a/GraphcodeKit/Sources/Sessions/ZmxLocator.swift b/GraphcodeKit/Sources/Sessions/ZmxLocator.swift index 7df5906e..af8d6427 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxLocator.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxLocator.swift @@ -7,7 +7,12 @@ import Foundation public enum ZmxLocator { public static var binaryURL: URL { #if os(Windows) - SupportDirectory.binDirectory.appendingPathComponent("zmx.exe") + resolve( + candidates( + executableDirectory: Bundle.main.executableURL?.deletingLastPathComponent(), + supportBinDirectory: SupportDirectory.binDirectory, + executableName: "zmx.exe"), + isExecutable: FileManager.default.isExecutableFile(atPath:)) #else SupportDirectory.binDirectory.appendingPathComponent("zmx") #endif @@ -16,4 +21,20 @@ public enum ZmxLocator { public static var isInstalled: Bool { FileManager.default.isExecutableFile(atPath: binaryURL.path) } + + /// The places `zmx` may live, most preferred first; the last is the support directory's + /// `bin`, which is also what is reported when none of them exists. The Windows package + /// installs `zmx.exe` beside `graphcoded.exe` and never copies it into the support + /// directory, so the running executable's own directory comes first. + static func candidates( + executableDirectory: URL?, supportBinDirectory: URL, executableName: String + ) -> [URL] { + let supportBinary = supportBinDirectory.appendingPathComponent(executableName) + guard let executableDirectory else { return [supportBinary] } + return [executableDirectory.appendingPathComponent(executableName), supportBinary] + } + + static func resolve(_ candidates: [URL], isExecutable: (String) -> Bool) -> URL { + candidates.first { isExecutable($0.path) } ?? candidates[candidates.count - 1] + } } diff --git a/graphcode/Tests/SupportDirectoryTests.swift b/graphcode/Tests/SupportDirectoryTests.swift index e0134ec5..c504c49f 100644 --- a/graphcode/Tests/SupportDirectoryTests.swift +++ b/graphcode/Tests/SupportDirectoryTests.swift @@ -26,6 +26,26 @@ struct SupportDirectoryTests { #expect(SupportDirectory.binDirectory.deletingLastPathComponent() == root) } + @Test + func zmxResolutionPrefersAnExecutableSiblingThenTheSupportBin() { + // Windows packages zmx.exe beside graphcoded; macOS keeps using the support bin, and + // `binaryURL` there is unchanged (asserted above). + let install = URL(fileURLWithPath: "/opt/graphcode/bin", isDirectory: true) + let support = SupportDirectory.binDirectory + let candidates = ZmxLocator.candidates( + executableDirectory: install, supportBinDirectory: support, executableName: "zmx") + let sibling = install.appendingPathComponent("zmx") + let supportZmx = support.appendingPathComponent("zmx") + + #expect(candidates == [sibling, supportZmx]) + #expect(ZmxLocator.resolve(candidates) { $0 == sibling.path } == sibling) + #expect(ZmxLocator.resolve(candidates) { $0 == supportZmx.path } == supportZmx) + #expect(ZmxLocator.resolve(candidates) { _ in false } == supportZmx) + #if !os(Windows) + #expect(ZmxLocator.binaryURL == supportZmx) + #endif + } + @Test func aBackslashOverrideRemainsRelativeOnDarwin() { let home = URL(fileURLWithPath: "/Users/test-user", isDirectory: true) diff --git a/windows-tests/WindowsDaemonTests.swift b/windows-tests/WindowsDaemonTests.swift index 7f60ff1f..572a9ca9 100644 --- a/windows-tests/WindowsDaemonTests.swift +++ b/windows-tests/WindowsDaemonTests.swift @@ -41,6 +41,34 @@ final class WindowsDaemonTests: XCTestCase { XCTAssertEqual(ZmxLocator.binaryURL.lastPathComponent, "zmx.exe") } + // The packaged daemon ships zmx.exe beside itself under %LOCALAPPDATA%\GraphCode; + // nothing copies it into the support directory, so the support bin alone left the + // daemon unable to launch, message or kill any loop session (beta9 Dev Box). + func testZmxLocatorPrefersThePackagedSiblingOfTheRunningExecutable() { + let install = URL(fileURLWithPath: #"C:\Users\u\AppData\Local\GraphCode\current\bin"#) + let support = URL(fileURLWithPath: #"C:\Users\u\.graphcode\bin"#) + let candidates = ZmxLocator.candidates( + executableDirectory: install, supportBinDirectory: support, executableName: "zmx.exe") + let sibling = install.appendingPathComponent("zmx.exe") + let supportZmx = support.appendingPathComponent("zmx.exe") + + XCTAssertEqual(candidates, [sibling, supportZmx]) + XCTAssertEqual(ZmxLocator.resolve(candidates) { $0 == sibling.path }, sibling) + XCTAssertEqual( + ZmxLocator.resolve(candidates) { [sibling.path, supportZmx.path].contains($0) }, sibling) + XCTAssertEqual(ZmxLocator.resolve(candidates) { $0 == supportZmx.path }, supportZmx) + // Neither present: report the support bin, so isInstalled stays false. + XCTAssertEqual(ZmxLocator.resolve(candidates) { _ in false }, supportZmx) + } + + func testZmxLocatorWithoutAnExecutableDirectoryUsesTheSupportBin() { + let support = URL(fileURLWithPath: #"C:\Users\u\.graphcode\bin"#) + XCTAssertEqual( + ZmxLocator.candidates( + executableDirectory: nil, supportBinDirectory: support, executableName: "zmx.exe"), + [support.appendingPathComponent("zmx.exe")]) + } + func testWindowsProviderProbeUsesWhere() { let invocation = ProviderPath.probeInvocation(for: "copilot") XCTAssertEqual(URL(fileURLWithPath: invocation[0]).lastPathComponent, "where.exe")