diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index 554811ab8..55168f100 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -15,6 +15,9 @@ import * as path from "path"; import { SwiftlyConfig } from "./ToolchainVersion"; import * as fs from "fs/promises"; +import * as fsSync from "fs"; +import * as os from "os"; +import * as readline from "readline"; import { execFile, ExecFileError } from "../utilities/utilities"; import * as vscode from "vscode"; import { Version } from "../utilities/version"; @@ -51,6 +54,45 @@ const InUseVersionResult = z.object({ version: z.string(), }); +const ListAvailableResult = z.object({ + toolchains: z.array( + z.object({ + version: z.discriminatedUnion("type", [ + z.object({ + major: z.union([z.number(), z.undefined()]), + minor: z.union([z.number(), z.undefined()]), + patch: z.union([z.number(), z.undefined()]), + name: z.string(), + type: z.literal("stable"), + }), + z.object({ + major: z.union([z.number(), z.undefined()]), + minor: z.union([z.number(), z.undefined()]), + branch: z.string(), + date: z.string(), + name: z.string(), + type: z.literal("snapshot"), + }), + ]), + }) + ), +}); + +export interface AvailableToolchain { + name: string; + type: "stable" | "snapshot"; + version: string; + isInstalled: boolean; +} + +export interface SwiftlyProgressData { + step?: { + text?: string; + timestamp?: number; + percent?: number; + }; +} + export class Swiftly { /** * Finds the version of Swiftly installed on the system. @@ -219,6 +261,147 @@ export class Swiftly { return undefined; } + /** + * Lists all toolchains available for installation from swiftly + * + * @param logger Optional logger for error reporting + * @returns Array of available toolchains + */ + public static async listAvailable(logger?: SwiftLogger): Promise { + if (!this.isSupported()) { + return []; + } + + const version = await Swiftly.version(logger); + if (!version) { + logger?.warn("Swiftly is not installed"); + return []; + } + + if (!(await Swiftly.supportsJsonOutput(logger))) { + logger?.warn("Swiftly version does not support JSON output for list-available"); + return []; + } + + try { + const { stdout: availableStdout } = await execFile("swiftly", [ + "list-available", + "--format=json", + ]); + const availableResponse = ListAvailableResult.parse(JSON.parse(availableStdout)); + + const { stdout: installedStdout } = await execFile("swiftly", [ + "list", + "--format=json", + ]); + const installedResponse = ListResult.parse(JSON.parse(installedStdout)); + const installedNames = new Set(installedResponse.toolchains.map(t => t.version.name)); + + return availableResponse.toolchains.map(toolchain => ({ + name: toolchain.version.name, + type: toolchain.version.type, + version: toolchain.version.name, + isInstalled: installedNames.has(toolchain.version.name), + })); + } catch (error) { + logger?.error(`Failed to retrieve available Swiftly toolchains: ${error}`); + return []; + } + } + + /** + * Installs a toolchain via swiftly with optional progress tracking + * + * @param version The toolchain version to install + * @param progressCallback Optional callback that receives progress data as JSON objects + * @param logger Optional logger for error reporting + */ + public static async installToolchain( + version: string, + progressCallback?: (progressData: SwiftlyProgressData) => void, + logger?: SwiftLogger + ): Promise { + if (!this.isSupported()) { + throw new Error("Swiftly is not supported on this platform"); + } + + if (process.platform === "linux") { + logger?.info( + `Skipping toolchain installation on Linux as it requires PostInstall steps` + ); + return; + } + + logger?.info(`Installing toolchain ${version} via swiftly`); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-")); + const postInstallFilePath = path.join(tmpDir, `post-install-${version}.sh`); + + let progressPipePath: string | undefined; + let progressPromise: Promise | undefined; + + if (progressCallback) { + progressPipePath = path.join(tmpDir, `progress-${version}.pipe`); + + await execFile("mkfifo", [progressPipePath]); + + progressPromise = new Promise((resolve, reject) => { + const rl = readline.createInterface({ + input: fsSync.createReadStream(progressPipePath!), + crlfDelay: Infinity, + }); + + rl.on("line", (line: string) => { + try { + const progressData = JSON.parse(line.trim()) as SwiftlyProgressData; + progressCallback(progressData); + } catch (err) { + logger?.error(`Failed to parse progress line: ${err}`); + } + }); + + rl.on("close", () => { + resolve(); + }); + + rl.on("error", err => { + reject(err); + }); + }); + } + + const installArgs = [ + "install", + version, + "--use", + "--assume-yes", + "--post-install-file", + postInstallFilePath, + ]; + + if (progressPipePath) { + installArgs.push("--progress-file", progressPipePath); + } + + try { + const installPromise = execFile("swiftly", installArgs); + + if (progressPromise) { + await Promise.all([installPromise, progressPromise]); + } else { + await installPromise; + } + } finally { + if (progressPipePath) { + try { + await fs.unlink(progressPipePath); + } catch { + // Ignore errors if the pipe file doesn't exist + } + } + } + } + /** * Reads the Swiftly configuration file, if it exists. * diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index d465ee61e..725b81cca 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -18,7 +18,7 @@ import { showReloadExtensionNotification } from "./ReloadExtension"; import { SwiftToolchain } from "../toolchain/toolchain"; import configuration from "../configuration"; import { Commands } from "../commands"; -import { Swiftly } from "../toolchain/swiftly"; +import { Swiftly, SwiftlyProgressData } from "../toolchain/swiftly"; import { SwiftLogger } from "../logging/SwiftLogger"; /** @@ -133,6 +133,12 @@ interface SwiftlyToolchainItem extends BaseSwiftToolchainItem { version: string; } +interface InstallableToolchainItem extends BaseSwiftToolchainItem { + category: "installable"; + version: string; + toolchainType: "stable" | "snapshot"; +} + /** A {@link vscode.QuickPickItem} that separates items in the UI */ class SeparatorItem implements vscode.QuickPickItem { readonly type = "separator"; @@ -145,7 +151,11 @@ class SeparatorItem implements vscode.QuickPickItem { } /** The possible types of {@link vscode.QuickPickItem} in the toolchain selection dialog */ -type SelectToolchainItem = SwiftToolchainItem | ActionItem | SeparatorItem; +type SelectToolchainItem = + | SwiftToolchainItem + | InstallableToolchainItem + | ActionItem + | SeparatorItem; /** * Retrieves all {@link SelectToolchainItem} that are available on the system. @@ -223,7 +233,72 @@ async function getQuickPickItems( } }, })); - // Mark which toolchain is being actively used + + const installableToolchains = + process.platform === "linux" + ? [] + : (await Swiftly.listAvailable(logger)) + .filter(toolchain => !toolchain.isInstalled) + .reverse() + .map(toolchain => ({ + type: "toolchain", + label: `$(cloud-download) ${toolchain.name} (${toolchain.type})`, + // detail: `Install ${toolchain.type} release`, + category: "installable", + version: toolchain.name, + toolchainType: toolchain.type, + onDidSelect: async () => { + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Installing Swift ${toolchain.name}`, + cancellable: false, + }, + async progress => { + progress.report({ message: "Starting installation..." }); + + let lastProgress = 0; + + await Swiftly.installToolchain( + toolchain.name, + (progressData: SwiftlyProgressData) => { + if ( + progressData.step?.percent !== undefined && + progressData.step.percent > lastProgress + ) { + const increment = + progressData.step.percent - lastProgress; + progress.report({ + increment, + message: + progressData.step.text || + `${progressData.step.percent}% complete`, + }); + lastProgress = progressData.step.percent; + } + }, + logger + ); + + progress.report({ + increment: 100 - lastProgress, + message: "Installation complete", + }); + } + ); + + void showReloadExtensionNotification( + `Swift ${toolchain.name} has been installed and activated. Visual Studio Code needs to be reloaded.` + ); + } catch (error) { + logger?.error(`Failed to install Swift ${toolchain.name}: ${error}`); + void vscode.window.showErrorMessage( + `Failed to install Swift ${toolchain.name}: ${error}` + ); + } + }, + })); if (activeToolchain) { const currentSwiftlyVersion = activeToolchain.isSwiftlyManaged ? await Swiftly.inUseVersion("swiftly", cwd) @@ -286,6 +361,9 @@ async function getQuickPickItems( ...(swiftlyToolchains.length > 0 ? [new SeparatorItem("swiftly"), ...swiftlyToolchains] : []), + ...(installableToolchains.length > 0 + ? [new SeparatorItem("available for install"), ...installableToolchains] + : []), new SeparatorItem("actions"), ...actionItems, ]; @@ -345,17 +423,11 @@ export async function showToolchainSelectionQuickPick( // Update the toolchain path` let swiftPath: string | undefined; - // Handle Swiftly toolchains specially - if (selected.category === "swiftly") { - try { - swiftPath = undefined; - } catch (error) { - void vscode.window.showErrorMessage(`Failed to switch Swiftly toolchain: ${error}`); - return; - } + if (selected.category === "swiftly" || selected.category === "installable") { + swiftPath = undefined; } else { // For non-Swiftly toolchains, use the swiftFolderPath - swiftPath = selected.swiftFolderPath; + swiftPath = (selected as PublicSwiftToolchainItem | XcodeToolchainItem).swiftFolderPath; } const isUpdated = await setToolchainPath(swiftPath, developerDir); diff --git a/test/unit-tests/toolchain/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts index fa48bea81..202cd0a80 100644 --- a/test/unit-tests/toolchain/swiftly.test.ts +++ b/test/unit-tests/toolchain/swiftly.test.ts @@ -13,16 +13,27 @@ //===----------------------------------------------------------------------===// import { expect } from "chai"; +import * as mockFS from "mock-fs"; +import * as os from "os"; +import { match } from "sinon"; import { Swiftly } from "../../../src/toolchain/swiftly"; import * as utilities from "../../../src/utilities/utilities"; import { mockGlobalModule, mockGlobalValue } from "../../MockUtils"; -suite("Swiftly Unit Tests", () => { +suite.only("Swiftly Unit Tests", () => { const mockUtilities = mockGlobalModule(utilities); const mockedPlatform = mockGlobalValue(process, "platform"); + const mockedEnv = mockGlobalValue(process, "env"); setup(() => { + mockFS({}); + mockUtilities.execFile.reset(); mockedPlatform.setValue("darwin"); + mockedEnv.setValue({}); + }); + + teardown(() => { + mockFS.restore(); }); suite("getSwiftlyToolchainInstalls", () => { @@ -102,4 +113,198 @@ suite("Swiftly Unit Tests", () => { expect(mockUtilities.execFile).not.have.been.called; }); }); + + suite("installToolchain", () => { + test("should throw error on unsupported platform", async () => { + mockedPlatform.setValue("win32"); + + await expect( + Swiftly.installToolchain("6.0.0", undefined) + ).to.eventually.be.rejectedWith("Swiftly is not supported on this platform"); + expect(mockUtilities.execFile).to.not.have.been.called; + }); + + test("should install toolchain successfully on macOS without progress callback", async () => { + mockedPlatform.setValue("darwin"); + mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); + + const tmpDir = os.tmpdir(); + mockFS({ + [tmpDir]: {}, + }); + + await Swiftly.installToolchain("6.0.0", undefined); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]); + }); + + test("should attempt to install toolchain with progress callback on macOS", async () => { + mockedPlatform.setValue("darwin"); + const progressCallback = () => {}; + + mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); + mockUtilities.execFile.withArgs("swiftly", match.array).resolves({ + stdout: "", + stderr: "", + }); + os.tmpdir(); + mockFS({}); + + // This test verifies the method starts the installation process + // The actual file stream handling is complex to mock properly + try { + await Swiftly.installToolchain("6.0.0", progressCallback); + } catch (error) { + // Expected due to mock-fs limitations with named pipes + expect((error as Error).message).to.include("ENOENT"); + } + + expect(mockUtilities.execFile).to.have.been.calledWith("mkfifo", match.array); + }); + + test("should handle installation error properly", async () => { + mockedPlatform.setValue("darwin"); + const installError = new Error("Installation failed"); + mockUtilities.execFile.withArgs("swiftly").rejects(installError); + + const tmpDir = os.tmpdir(); + mockFS({ + [tmpDir]: {}, + }); + + await expect( + Swiftly.installToolchain("6.0.0", undefined) + ).to.eventually.be.rejectedWith("Installation failed"); + }); + }); + + suite("listAvailable", () => { + test("should return empty array on unsupported platform", async () => { + mockedPlatform.setValue("win32"); + + const result = await Swiftly.listAvailable(); + + expect(result).to.deep.equal([]); + }); + + test("should return empty array when Swiftly is not installed", async () => { + mockedPlatform.setValue("darwin"); + mockUtilities.execFile + .withArgs("swiftly", ["--version"]) + .rejects(new Error("Command not found")); + + const result = await Swiftly.listAvailable(); + + expect(result).to.deep.equal([]); + }); + + test("should return empty array when Swiftly version doesn't support JSON output", async () => { + mockedPlatform.setValue("darwin"); + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.0.0\n", + stderr: "", + }); + + const result = await Swiftly.listAvailable(); + + expect(result).to.deep.equal([]); + }); + + test("should return available toolchains with installation status", async () => { + mockedPlatform.setValue("darwin"); + + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + + const availableResponse = { + toolchains: [ + { + version: { + type: "stable", + major: 6, + minor: 0, + patch: 0, + name: "6.0.0", + }, + }, + { + version: { + type: "snapshot", + major: 6, + minor: 1, + branch: "main", + date: "2025-01-15", + name: "main-snapshot-2025-01-15", + }, + }, + ], + }; + + mockUtilities.execFile + .withArgs("swiftly", ["list-available", "--format=json"]) + .resolves({ + stdout: JSON.stringify(availableResponse), + stderr: "", + }); + + const installedResponse = { + toolchains: [ + { + inUse: true, + isDefault: true, + version: { + type: "stable", + major: 6, + minor: 0, + patch: 0, + name: "6.0.0", + }, + }, + ], + }; + + mockUtilities.execFile.withArgs("swiftly", ["list", "--format=json"]).resolves({ + stdout: JSON.stringify(installedResponse), + stderr: "", + }); + + const result = await Swiftly.listAvailable(); + expect(result).to.deep.equal([ + { + name: "6.0.0", + type: "stable", + version: "6.0.0", + isInstalled: true, + }, + { + name: "main-snapshot-2025-01-15", + type: "snapshot", + version: "main-snapshot-2025-01-15", + isInstalled: false, + }, + ]); + }); + + test("should handle errors when fetching available toolchains", async () => { + mockedPlatform.setValue("darwin"); + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + mockUtilities.execFile + .withArgs("swiftly", ["list-available", "--format=json"]) + .rejects(new Error("Network error")); + const result = await Swiftly.listAvailable(); + expect(result).to.deep.equal([]); + }); + }); });