diff --git a/package-lock.json b/package-lock.json index ef58c1cb4..8eae8265d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "fast-glob": "^3.3.3", "lcov-parse": "^1.0.0", "plist": "^3.1.0", + "tar": "^6.2.1", "vscode-languageclient": "^9.0.1", "xml2js": "^0.6.2", "zod": "^4.1.5" @@ -40,6 +41,7 @@ "@types/sinon-chai": "^3.2.12", "@types/svg2ttf": "^5.0.3", "@types/svgicons2svgfont": "^10.0.5", + "@types/tar": "^6.1.13", "@types/ttf2woff": "^2.0.4", "@types/vscode": "^1.88.0", "@types/xml2js": "^0.4.14", @@ -2373,6 +2375,27 @@ "@types/node": "*" } }, + "node_modules/@types/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "minipass": "^4.0.0" + } + }, + "node_modules/@types/tar/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -5989,7 +6012,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -6002,7 +6024,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -7905,7 +7926,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, "license": "MIT", "dependencies": { "minipass": "^3.0.0", @@ -7919,7 +7939,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -7932,7 +7951,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" @@ -10566,7 +10584,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, "license": "ISC", "dependencies": { "chownr": "^2.0.0", @@ -10629,7 +10646,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -10639,7 +10655,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=8" @@ -11522,8 +11537,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { "version": "2.8.1", @@ -13306,6 +13320,24 @@ "@types/node": "*" } }, + "@types/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", + "dev": true, + "requires": { + "@types/node": "*", + "minipass": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true + } + } + }, "@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -15923,7 +15955,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, "requires": { "minipass": "^3.0.0" }, @@ -15932,7 +15963,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -17316,7 +17346,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, "requires": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -17326,7 +17355,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -17336,8 +17364,7 @@ "mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, "mkdirp-classic": { "version": "0.5.3", @@ -19190,7 +19217,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -19203,14 +19229,12 @@ "chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" }, "minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==" } } }, @@ -19904,8 +19928,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yaml": { "version": "2.8.1", diff --git a/package.json b/package.json index 21af60a52..c63a68312 100644 --- a/package.json +++ b/package.json @@ -1083,6 +1083,13 @@ "order": 4, "scope": "machine-overridable" }, + "swift.suppressSwiftlyInstallPrompt": { + "type": "boolean", + "default": false, + "markdownDescription": "Suppress the automatic Swiftly installation prompt when no Swift toolchain is found.", + "order": 98, + "scope": "application" + }, "swift.diagnostics": { "type": "boolean", "default": false, @@ -2033,6 +2040,7 @@ "@types/sinon-chai": "^3.2.12", "@types/svg2ttf": "^5.0.3", "@types/svgicons2svgfont": "^10.0.5", + "@types/tar": "^6.1.13", "@types/ttf2woff": "^2.0.4", "@types/vscode": "^1.88.0", "@types/xml2js": "^0.4.14", @@ -2081,6 +2089,7 @@ "fast-glob": "^3.3.3", "lcov-parse": "^1.0.0", "plist": "^3.1.0", + "tar": "^6.2.1", "vscode-languageclient": "^9.0.1", "xml2js": "^0.6.2", "zod": "^4.1.5" diff --git a/src/PackageWatcher.ts b/src/PackageWatcher.ts index 25e5618d5..092550d7c 100644 --- a/src/PackageWatcher.ts +++ b/src/PackageWatcher.ts @@ -17,8 +17,10 @@ import * as vscode from "vscode"; import { FolderContext } from "./FolderContext"; import { FolderOperation } from "./WorkspaceContext"; +import { handleMissingSwiftly } from "./commands/installSwiftly"; import { SwiftLogger } from "./logging/SwiftLogger"; import { BuildFlags } from "./toolchain/BuildFlags"; +import { handleMissingSwiftlyToolchain } from "./toolchain/swiftly"; import { showReloadExtensionNotification } from "./ui/ReloadExtension"; import { fileExists } from "./utilities/filesystem"; import { Version } from "./utilities/version"; @@ -140,6 +142,30 @@ export class PackageWatcher { async handleSwiftVersionFileChange() { const version = await this.readSwiftVersionFile(); if (version?.toString() !== this.currentVersion?.toString()) { + if (version) { + const swiftlyInstalled = await handleMissingSwiftly(this.logger); + if (swiftlyInstalled) { + const toolchainInstalled = await handleMissingSwiftlyToolchain( + version.toString(), + this.logger, + this.folderContext.folder + ); + if (toolchainInstalled) { + // Build dynamic message based on installation results + const message = + "Swiftly and Swift toolchain have been installed. Please reload the extension to use the new toolchain."; + await showReloadExtensionNotification(message); + return; + } else { + // Only Swiftly was installed + const message = + "Swiftly has been installed. Please reload the extension to continue."; + await showReloadExtensionNotification(message); + return; + } + } + } + await this.folderContext.fireEvent(FolderOperation.swiftVersionUpdated); await showReloadExtensionNotification( "Changing the swift toolchain version requires the extension to be reloaded" diff --git a/src/commands/installSwiftly.ts b/src/commands/installSwiftly.ts new file mode 100644 index 000000000..e0364e038 --- /dev/null +++ b/src/commands/installSwiftly.ts @@ -0,0 +1,198 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 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 os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; + +import { SwiftLogger } from "../logging/SwiftLogger"; +import { Swiftly } from "../toolchain/swiftly"; + +interface SwiftlyInstallOptions { + swiftlyHomeDir?: string; + swiftlyBinDir?: string; +} + +/** + * Prompts user for Swiftly installation with directory customization options + * @param logger Optional logger + * @returns Promise Installation options if user wants to install, null otherwise + */ +export async function promptForSwiftlyInstallation( + logger?: SwiftLogger +): Promise { + const installMessage = `Swiftly is not installed on your system. Swiftly is a Swift toolchain installer and manager that allows you to easily install and switch between different versions of Swift. + +Would you like to install Swiftly now?`; + + const selection = await vscode.window.showInformationMessage( + installMessage, + { modal: true }, + "Install Swiftly", + "Customize Directories", + "Don't Show Again", + "Cancel" + ); + + switch (selection) { + case "Install Swiftly": + return {}; // Use defaults + + case "Customize Directories": + return await promptForDirectoryCustomization(logger); + + case "Don't Show Again": + // Set a workspace setting to suppress this prompt + await vscode.workspace + .getConfiguration("swift") + .update("suppressSwiftlyInstallPrompt", true, vscode.ConfigurationTarget.Global); + logger?.info("Swiftly installation prompt suppressed by user"); + return null; + + case "Cancel": + default: + return null; + } +} + +/** + * Prompts user to customize Swiftly installation directories + * @param logger Optional logger + * @returns Promise + */ +async function promptForDirectoryCustomization( + logger?: SwiftLogger +): Promise { + const homeDir = os.homedir(); + const defaultSwiftlyHome = path.join(homeDir, ".swiftly"); + const defaultSwiftlyBin = path.join(homeDir, ".local", "bin"); + + const customHomeDir = await vscode.window.showInputBox({ + title: "Customize Swiftly Home Directory", + prompt: "Enter the directory where Swiftly will store its data and toolchains", + value: defaultSwiftlyHome, + placeHolder: defaultSwiftlyHome, + validateInput: value => { + if (!value || value.trim().length === 0) { + return "Directory path cannot be empty"; + } + if (!path.isAbsolute(value)) { + return "Please provide an absolute path"; + } + return null; + }, + }); + + if (customHomeDir === undefined) { + return null; // User cancelled + } + + const customBinDir = await vscode.window.showInputBox({ + title: "Customize Swiftly Binary Directory", + prompt: "Enter the directory where Swiftly binaries will be installed", + value: defaultSwiftlyBin, + placeHolder: defaultSwiftlyBin, + validateInput: value => { + if (!value || value.trim().length === 0) { + return "Directory path cannot be empty"; + } + if (!path.isAbsolute(value)) { + return "Please provide an absolute path"; + } + return null; + }, + }); + + if (customBinDir === undefined) { + return null; // User cancelled + } + + logger?.info(`User customized Swiftly directories: home=${customHomeDir}, bin=${customBinDir}`); + + return { + swiftlyHomeDir: customHomeDir.trim(), + swiftlyBinDir: customBinDir.trim(), + }; +} + +/** + * Installs Swiftly with progress tracking and user feedback + * @param options Installation options + * @param logger Optional logger + * @returns Promise true if installation succeeded + */ +export async function installSwiftlyWithProgress( + options: SwiftlyInstallOptions, + logger?: SwiftLogger +): Promise { + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Installing Swiftly", + cancellable: false, + }, + async progress => { + await Swiftly.installSwiftly( + progress, + logger, + options.swiftlyHomeDir, + options.swiftlyBinDir + ); + } + ); + return true; + } catch (error) { + logger?.error(`Failed to install Swiftly: ${error}`); + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage(`Failed to install Swiftly: ${message}`); + return false; + } +} + +/** + * Checks if the Swiftly installation prompt should be suppressed + * @returns true if suppressed, false otherwise + */ +export function isSwiftlyPromptSuppressed(): boolean { + return vscode.workspace.getConfiguration("swift").get("suppressSwiftlyInstallPrompt", false); +} + +/** + * Main function to handle missing Swiftly detection and installation + * @param logger Optional logger + * @returns Promise true if Swiftly was installed or already exists + */ +export async function handleMissingSwiftly(logger?: SwiftLogger): Promise { + // Check if Swiftly is missing + if (await Swiftly.isInstalled()) { + return true; // Swiftly is already installed + } + + // Check if prompt is suppressed + if (isSwiftlyPromptSuppressed()) { + logger?.debug("Swiftly installation prompt is suppressed"); + return false; + } + + // Prompt user for installation + const options = await promptForSwiftlyInstallation(logger); + if (!options) { + return false; // User cancelled or suppressed + } + + // Install Swiftly + const installSuccess = await installSwiftlyWithProgress(options, logger); + + return installSuccess; +} diff --git a/src/commands/installSwiftlyToolchain.ts b/src/commands/installSwiftlyToolchain.ts index 4bfe58eff..3e6b05f49 100644 --- a/src/commands/installSwiftlyToolchain.ts +++ b/src/commands/installSwiftlyToolchain.ts @@ -17,6 +17,7 @@ import { WorkspaceContext } from "../WorkspaceContext"; import { SwiftLogger } from "../logging/SwiftLogger"; import { Swiftly, SwiftlyProgressData } from "../toolchain/swiftly"; import { askWhereToSetToolchain } from "../ui/ToolchainSelection"; +import { handleMissingSwiftly } from "./installSwiftly"; /** * Installs a Swiftly toolchain and shows a progress notification to the user. @@ -101,10 +102,10 @@ export async function promptToInstallSwiftlyToolchain( if (!(await Swiftly.isInstalled())) { ctx.logger?.warn("Swiftly is not installed."); - void vscode.window.showErrorMessage( - "Swiftly is not installed. Please install Swiftly first from https://www.swift.org/install/" - ); - return; + const swiftlyInstalled = await handleMissingSwiftly(ctx.logger); + if (!swiftlyInstalled) { + return; + } } let branch: string | undefined = undefined; diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index 771a0cceb..d842d064e 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -18,6 +18,7 @@ import * as os from "os"; import * as path from "path"; import * as readline from "readline"; import * as Stream from "stream"; +import { extract } from "tar"; import * as vscode from "vscode"; import { z } from "zod/v4/mini"; @@ -172,6 +173,237 @@ export async function handleMissingSwiftlyToolchain( export class Swiftly { public static cancellationMessage = "Installation cancelled by user"; + /** + * Downloads and installs Swiftly for the current platform + */ + public static async installSwiftly( + progress: vscode.Progress<{ message?: string; increment?: number }>, + logger?: SwiftLogger, + swiftlyHomeDir?: string, + swiftlyBinDir?: string + ): Promise { + if (!this.isSupported()) { + throw new Error("Swiftly is not supported on this platform"); + } + + switch (process.platform) { + case "darwin": + await this.installSwiftlyDarwin(progress, logger, swiftlyHomeDir, swiftlyBinDir); + break; + case "linux": + await this.installSwiftlyLinux(progress, logger, swiftlyHomeDir, swiftlyBinDir); + break; + default: + throw new Error(`Swiftly installation is not supported on ${process.platform}`); + } + } + + private static async installSwiftlyDarwin( + progress: vscode.Progress<{ message?: string; increment?: number }>, + logger?: SwiftLogger, + swiftlyHomeDir?: string, + swiftlyBinDir?: string + ): Promise { + const url = "https://download.swift.org/swiftly/darwin/swiftly.pkg"; + const downloadedPkgPath = await this.downloadSwiftlyInstaller(url, progress, logger); + + try { + progress.report({ message: "Installing Swiftly package..." }); + + await execFile("installer", [ + "-pkg", + downloadedPkgPath, + "-target", + "CurrentUserHomeDirectory", + ]); + + progress.report({ message: "Initializing Swiftly..." }); + + const env = { ...process.env }; + if (swiftlyHomeDir) { + env["SWIFTLY_HOME_DIR"] = swiftlyHomeDir; + } + if (swiftlyBinDir) { + env["SWIFTLY_BIN_DIR"] = swiftlyBinDir; + } + + const actualSwiftlyHomeDir = swiftlyHomeDir || path.join(os.homedir(), ".swiftly"); + const swiftlyPath = path.join(actualSwiftlyHomeDir, "bin", "swiftly"); + + await execFile(swiftlyPath, ["init", "--quiet-shell-followup"], { env }); + + progress.report({ message: "Swiftly installation completed", increment: 100 }); + logger?.info("Swiftly installation and initialization completed successfully"); + } catch (error) { + logger?.error(`Failed to install Swiftly: ${error}`); + throw new Error(`Failed to install Swiftly on macOS: ${(error as Error).message}`); + } finally { + try { + await fs.unlink(downloadedPkgPath); + await fs.rm(path.dirname(downloadedPkgPath), { recursive: true }); + } catch { + // Ignore cleanup errors + } + } + } + + private static async installSwiftlyLinux( + progress: vscode.Progress<{ message?: string; increment?: number }>, + logger?: SwiftLogger, + swiftlyHomeDir?: string, + swiftlyBinDir?: string + ): Promise { + const env = { ...process.env }; + if (swiftlyHomeDir) { + env["SWIFTLY_HOME_DIR"] = swiftlyHomeDir; + } + if (swiftlyBinDir) { + env["SWIFTLY_BIN_DIR"] = swiftlyBinDir; + } + + let tmpDir: string | undefined; + + try { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-")); + + progress.report({ message: "Downloading Swiftly for Linux..." }); + + const archMap: Record = { + x64: "x86_64", + arm64: "aarch64", + }; + const architecture = archMap[os.arch()] || os.arch(); + const url = `https://download.swift.org/swiftly/linux/swiftly-${architecture}.tar.gz`; + const downloadedTarPath = await this.downloadSwiftlyInstaller(url, progress, logger); + + progress.report({ message: "Extracting Swiftly..." }); + + await extract({ + file: downloadedTarPath, + cwd: tmpDir, + }); + + progress.report({ message: "Initializing Swiftly..." }); + + await execFile("./swiftly", ["init", "--quiet-shell-followup"], { + cwd: tmpDir, + env, + }); + + progress.report({ message: "Swiftly installation completed", increment: 100 }); + logger?.info("Swiftly installation completed successfully on Linux"); + } catch (error) { + logger?.error(`Failed to install Swiftly on Linux: ${error}`); + throw new Error(`Failed to install Swiftly on Linux: ${(error as Error).message}`); + } finally { + if (tmpDir) { + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } + } + + private static async downloadSwiftlyInstaller( + url: string, + progress: vscode.Progress<{ message?: string; increment?: number }>, + logger?: SwiftLogger + ): Promise { + progress.report({ message: "Downloading Swiftly installer..." }); + + let tmpDir: string | undefined; + let filePath: string | undefined; + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download installer: HTTP ${response.status}`); + } + + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-")); + const fileName = path.basename(url) || "swiftly-installer"; + filePath = path.join(tmpDir, fileName); + + if (!response.body) { + throw new Error("Response body is null"); + } + + const contentLength = response.headers.get("content-length"); + const totalLength = contentLength ? parseInt(contentLength, 10) : 0; + let downloadedLength = 0; + let lastReportedPercent = 0; + + const fileStream = fsSync.createWriteStream(filePath); + const reader = response.body.getReader(); + + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + downloadedLength += value.length; + fileStream.write(value); + + if (totalLength > 0) { + const percent = Math.floor((downloadedLength / totalLength) * 100); + if (percent > lastReportedPercent && percent % 10 === 0) { + progress.report({ + message: `Downloading Swiftly installer... ${percent}%`, + increment: percent - lastReportedPercent, + }); + lastReportedPercent = percent; + } + } + } + } finally { + reader.releaseLock(); + fileStream.end(); + } + + await new Promise((resolve, reject) => { + fileStream.on("finish", () => { + fileStream.close(err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + fileStream.on("error", reject); + }); + + progress.report({ message: "Download completed" }); + logger?.info(`Swiftly installer downloaded to: ${filePath}`); + + return filePath; + } catch (error) { + // Cleanup temporary resources on error + if (filePath) { + try { + await fs.unlink(filePath); + } catch { + // Swallow cleanup errors + } + } + if (tmpDir) { + try { + await fs.rm(tmpDir, { recursive: true }); + } catch { + // Swallow cleanup errors + } + } + + logger?.error(`Failed to download Swiftly installer: ${error}`); + throw error; + } + } + /** * Finds the version of Swiftly installed on the system. *