From 8d6c0dbb604834cd3e9cec2de64b4ee58e85d590 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Wed, 4 Dec 2024 15:08:15 -0500 Subject: [PATCH] Prompt to restart extension on `xcode-select` If the user performs an `xcode-select` and doesn't have a `swift.path` set in their VS Code settings, prompt them to restart the extension to pick up the new Xcode path. This only applies on macOS where `xcode-select` is possible. --- src/extension.ts | 2 + src/toolchain/SelectedXcodeWatcher.ts | 93 +++++++++++++++++++ .../toolchain/SelectedXcodeWatcher.test.ts | 70 ++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 src/toolchain/SelectedXcodeWatcher.ts create mode 100644 test/unit-tests/toolchain/SelectedXcodeWatcher.test.ts diff --git a/src/extension.ts b/src/extension.ts index f2dc998c2..2e5f1f184 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -38,6 +38,7 @@ import { showReloadExtensionNotification } from "./ui/ReloadExtension"; import { checkAndWarnAboutWindowsSymlinks } from "./ui/win32"; import { SwiftEnvironmentVariablesManager, SwiftTerminalProfileProvider } from "./terminal"; import { resolveFolderDependencies } from "./commands/dependencies/resolve"; +import { SelectedXcodeWatcher } from "./toolchain/SelectedXcodeWatcher"; /** * External API as exposed by the extension. Can be queried by other extensions @@ -125,6 +126,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { context.subscriptions.push(...commands.register(workspaceContext)); context.subscriptions.push(workspaceContext); context.subscriptions.push(registerDebugger(workspaceContext)); + context.subscriptions.push(new SelectedXcodeWatcher(outputChannel)); // listen for workspace folder changes and active text editor changes workspaceContext.setupEventListeners(); diff --git a/src/toolchain/SelectedXcodeWatcher.ts b/src/toolchain/SelectedXcodeWatcher.ts new file mode 100644 index 000000000..30778edf6 --- /dev/null +++ b/src/toolchain/SelectedXcodeWatcher.ts @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2021-2024 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import * as fs from "fs/promises"; +import * as vscode from "vscode"; +import { SwiftOutputChannel } from "../ui/SwiftOutputChannel"; +import { showReloadExtensionNotification } from "../ui/ReloadExtension"; +import configuration from "../configuration"; + +export class SelectedXcodeWatcher implements vscode.Disposable { + private xcodePath: string | undefined; + private disposed: boolean = false; + private interval: NodeJS.Timeout | undefined; + private checkIntervalMs: number; + private xcodeSymlink: () => Promise; + + private static DEFAULT_CHECK_INTERVAL_MS = 2000; + private static XCODE_SYMLINK_LOCATION = "/var/select/developer_dir"; + + constructor( + private outputChannel: SwiftOutputChannel, + testDependencies?: { + checkIntervalMs?: number; + xcodeSymlink?: () => Promise; + } + ) { + this.checkIntervalMs = + testDependencies?.checkIntervalMs || SelectedXcodeWatcher.DEFAULT_CHECK_INTERVAL_MS; + this.xcodeSymlink = + testDependencies?.xcodeSymlink || + (async () => { + try { + return await fs.readlink(SelectedXcodeWatcher.XCODE_SYMLINK_LOCATION); + } catch (e) { + return undefined; + } + }); + + if (!this.isValidXcodePlatform()) { + return; + } + + // Deliberately not awaiting this, as we don't want to block the extension activation. + this.setup(); + } + + dispose() { + this.disposed = true; + clearInterval(this.interval); + } + + /** + * Polls the Xcode symlink location checking if it has changed. + * If the user has `swift.path` set in their settings this check is skipped. + */ + private async setup() { + this.xcodePath = await this.xcodeSymlink(); + this.interval = setInterval(async () => { + if (this.disposed) { + return clearInterval(this.interval); + } + + const newXcodePath = await this.xcodeSymlink(); + if (!configuration.path && newXcodePath && this.xcodePath !== newXcodePath) { + this.outputChannel.appendLine( + `Selected Xcode changed from ${this.xcodePath} to ${newXcodePath}` + ); + showReloadExtensionNotification( + "The Swift Extension has detected a change in the selected Xcode. Please reload the extension to apply the changes." + ); + this.xcodePath = newXcodePath; + } + }, this.checkIntervalMs); + } + + /** + * Xcode selection is a macOS only concept. + */ + private isValidXcodePlatform() { + return process.platform === "darwin"; + } +} diff --git a/test/unit-tests/toolchain/SelectedXcodeWatcher.test.ts b/test/unit-tests/toolchain/SelectedXcodeWatcher.test.ts new file mode 100644 index 000000000..b48b02e71 --- /dev/null +++ b/test/unit-tests/toolchain/SelectedXcodeWatcher.test.ts @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2024 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import * as vscode from "vscode"; +import { expect } from "chai"; +import { SelectedXcodeWatcher } from "../../../src/toolchain/SelectedXcodeWatcher"; +import { SwiftOutputChannel } from "../../../src/ui/SwiftOutputChannel"; +import { instance, MockedObject, mockFn, mockGlobalObject, mockObject } from "../../MockUtils"; + +suite("Selected Xcode Watcher", () => { + const mockedVSCodeWindow = mockGlobalObject(vscode, "window"); + let mockOutputChannel: MockedObject; + + setup(() => { + mockOutputChannel = mockObject({ + appendLine: mockFn(), + }); + }); + + async function run(symLinksOnCallback: (string | undefined)[]) { + return new Promise(resolve => { + let ctr = 0; + const watcher = new SelectedXcodeWatcher(instance(mockOutputChannel), { + checkIntervalMs: 1, + xcodeSymlink: async () => { + if (ctr >= symLinksOnCallback.length) { + watcher.dispose(); + resolve(); + return; + } + const response = symLinksOnCallback[ctr]; + ctr += 1; + return response; + }, + }); + }); + } + + test("Does nothing when the symlink is undefined", async () => { + await run([undefined, undefined]); + + expect(mockedVSCodeWindow.showWarningMessage).to.have.not.been.called; + }); + + test("Does nothing when the symlink is identical", async () => { + await run(["/foo", "/foo"]); + + expect(mockedVSCodeWindow.showWarningMessage).to.have.not.been.called; + }); + + test("Prompts to restart when the symlink changes", async () => { + await run(["/foo", "/bar"]); + + expect(mockedVSCodeWindow.showWarningMessage).to.have.been.calledOnceWithExactly( + "The Swift Extension has detected a change in the selected Xcode. Please reload the extension to apply the changes.", + "Reload Extensions" + ); + }); +});