From b3d9bddc33f2998dcfe2ed9a4c4b2c7699929619 Mon Sep 17 00:00:00 2001 From: Techatrix Date: Tue, 24 Sep 2024 17:54:28 +0200 Subject: [PATCH] add a VersionManager that installs Zig and ZLS See the TODOs on what needs to be added to the VersionManager fixes #111 --- src/versionManager.ts | 159 ++++++++++++++++++++++++++++++++++++++++++ src/zigSetup.ts | 43 ++++++------ src/zigUtil.ts | 135 ++++++++--------------------------- src/zls.ts | 32 +++------ 4 files changed, 223 insertions(+), 146 deletions(-) create mode 100644 src/versionManager.ts diff --git a/src/versionManager.ts b/src/versionManager.ts new file mode 100644 index 0000000..5e98597 --- /dev/null +++ b/src/versionManager.ts @@ -0,0 +1,159 @@ +import vscode from "vscode"; + +import childProcess from "child_process"; +import fs from "fs"; +import util from "util"; +import which from "which"; + +import axios from "axios"; +import semver from "semver"; + +import { getZigArchName, getZigOSName } from "./zigUtil"; + +const execFile = util.promisify(childProcess.execFile); +const chmod = util.promisify(fs.chmod); + +/** + * A version manager for Zig and ZLS. + * + * Expects a provider that follows the following scheme: + * `${PROVIDER_URL}/${NAME}-${OS}-${ARCH}-${VERSION}.${FILE_EXTENSION}` + * + * Example: + * - `https://ziglang.org/download/0.13.0/zig-windows-x86_64-0.13.0.zip` + * - `https://builds.zigtools.org/zls-linux-x86_64-0.13.0.tar.xz` + * + * TODO automatically remove unnecessary versions + * Maybe limit the number of versions and track how long the version was not used. + * + * TODO verify installation with minisig + */ +export class VersionManager { + context: vscode.ExtensionContext; + kind: "zig" | "zls"; + + /** The maxmimum number of installation that can be store until they will be removed */ + static maxInstallCount = 5; + + constructor(context: vscode.ExtensionContext, kind: "zig" | "zls") { + this.context = context; + this.kind = kind; + } + + /** Returns the path to the executable */ + public async install(version: semver.SemVer): Promise { + let title: string; + let artifactBaseUrl: vscode.Uri; + let extraTarArgs: string[]; + switch (this.kind) { + case "zig": + title = "Zig"; + if (version.prerelease.length === 0) { + artifactBaseUrl = vscode.Uri.joinPath( + vscode.Uri.parse("https://ziglang.org/download"), + version.raw, + ); + } else { + artifactBaseUrl = vscode.Uri.parse("https://ziglang.org/builds"); + } + extraTarArgs = ["--strip-components=1"]; + break; + case "zls": + title = "ZLS"; + artifactBaseUrl = vscode.Uri.parse("https://builds.zigtools.org"); + break; + } + + const isWindows = process.platform === "win32"; + const fileExtension = process.platform === "win32" ? "zip" : "tar.xz"; + const exeName = this.kind + (isWindows ? ".exe" : ""); + const subDirName = `${getZigOSName()}-${getZigArchName()}-${version.raw}`; + const fileName = `${this.kind}-${subDirName}.${fileExtension}`; + + const artifactUrl = vscode.Uri.joinPath(artifactBaseUrl, fileName); + + const installDir = vscode.Uri.joinPath(this.context.globalStorageUri, this.kind, subDirName); + const exeUri = vscode.Uri.joinPath(installDir, exeName); + const exePath = exeUri.fsPath; + const tarballUri = vscode.Uri.joinPath(installDir, fileName); + + try { + await vscode.workspace.fs.stat(exeUri); + return exePath; + } catch (e) { + if (e instanceof vscode.FileSystemError) { + if (e.code !== "FileNotFound") { + throw e; + } + // go ahead an install + } else { + throw e; + } + } + + const tarPath = await which("tar", { nothrow: true }); + if (!tarPath) { + throw new Error(`Downloaded ${title} tarball can't be extracted because 'tar' could not be found`); + } + + return await vscode.window.withProgress( + { + title: `Installing ${title}`, + location: vscode.ProgressLocation.Notification, + }, + async (progress, cancelToken) => { + const abortController = new AbortController(); + cancelToken.onCancellationRequested(() => { + abortController.abort(); + }); + + const response = await axios.get(artifactUrl.toString(), { + responseType: "arraybuffer", + signal: abortController.signal, + onDownloadProgress: (progressEvent) => { + if (progressEvent.total) { + const increment = (progressEvent.bytes / progressEvent.total) * 100; + progress.report({ + message: progressEvent.progress + ? `downloading tarball ${(progressEvent.progress * 100).toFixed()}%` + : "downloading tarball...", + increment: increment, + }); + } + }, + }); + + try { + await vscode.workspace.fs.delete(installDir, { recursive: true, useTrash: false }); + } catch {} + await vscode.workspace.fs.createDirectory(installDir); + await vscode.workspace.fs.writeFile(tarballUri, response.data); + + progress.report({ message: "Extracting..." }); + try { + await execFile(tarPath, ["-xf", tarballUri.fsPath, "-C", installDir.fsPath].concat(extraTarArgs), { + signal: abortController.signal, + timeout: 60000, // 60 seconds + }); + } catch (err) { + try { + await vscode.workspace.fs.delete(installDir, { recursive: true, useTrash: false }); + } catch {} + if (err instanceof Error) { + throw new Error(`Failed to extract ${title} tarball: ${err.message}`); + } else { + throw err; + } + } finally { + try { + await vscode.workspace.fs.delete(tarballUri, { useTrash: false }); + } catch {} + } + + await chmod(exePath, 0o755); + + return exePath; + }, + ); + } +} diff --git a/src/zigSetup.ts b/src/zigSetup.ts index ab5f062..38490fd 100644 --- a/src/zigSetup.ts +++ b/src/zigSetup.ts @@ -4,9 +4,12 @@ import axios from "axios"; import semver from "semver"; import vscode from "vscode"; -import { downloadAndExtractArtifact, getHostZigName, getVersion, getZigPath, shouldCheckUpdate } from "./zigUtil"; +import { getHostZigName, getVersion, getZigPath, shouldCheckUpdate } from "./zigUtil"; +import { VersionManager } from "./versionManager"; import { restartClient } from "./zls"; +let versionManager: VersionManager; + const DOWNLOAD_INDEX = "https://ziglang.org/download/index.json"; function getNightlySemVer(url: string): string { @@ -22,27 +25,20 @@ interface ZigVersion { url: string; sha: string; notes?: string; + version: semver.SemVer; } -export async function installZig(context: vscode.ExtensionContext, version: ZigVersion) { - const zigPath = await downloadAndExtractArtifact( - "Zig", - "zig", - vscode.Uri.joinPath(context.globalStorageUri, "zig_install"), - version.url, - version.sha, - ["--strip-components=1"], - ); - if (zigPath !== null) { - const configuration = vscode.workspace.getConfiguration("zig"); - await configuration.update("path", zigPath, true); +export async function installZig(context: vscode.ExtensionContext, version: semver.SemVer) { + const zigPath = await versionManager.install(version); - void vscode.window.showInformationMessage( - `Zig has been installed successfully. Relaunch your integrated terminal to make it available.`, - ); + const configuration = vscode.workspace.getConfiguration("zig"); + await configuration.update("path", zigPath, true); - void restartClient(context); - } + void vscode.window.showInformationMessage( + `Zig has been installed successfully. Relaunch your integrated terminal to make it available.`, + ); + + void restartClient(context); } async function getVersions(): Promise { @@ -51,13 +47,18 @@ async function getVersions(): Promise { const result: ZigVersion[] = []; for (let key in indexJson) { const value = indexJson[key]; + let version: semver.SemVer; if (key === "master") { key = "nightly"; + version = new semver.SemVer((value as unknown as { version: string }).version); + } else { + version = new semver.SemVer(key); } const release = value[hostName]; if (release) { result.push({ name: key, + version: version, url: release.tarball, sha: release.shasum, notes: (value as { notes?: string }).notes, @@ -90,7 +91,7 @@ async function selectVersionAndInstall(context: vscode.ExtensionContext) { if (selection === undefined) return; for (const option of available) { if (option.name === selection.label) { - await installZig(context, option); + await installZig(context, option.version); return; } } @@ -117,7 +118,7 @@ async function checkUpdate(context: vscode.ExtensionContext) { ); switch (response) { case "Install": - await installZig(context, update); + await installZig(context, update.version); break; case "Ignore": case undefined: @@ -190,6 +191,8 @@ export async function setupZig(context: vscode.ExtensionContext) { } } + versionManager = new VersionManager(context, "zig"); + context.environmentVariableCollection.description = "Add Zig to PATH"; updateZigEnvironmentVariableCollection(context); diff --git a/src/zigUtil.ts b/src/zigUtil.ts index ad5916c..9d7d8b8 100644 --- a/src/zigUtil.ts +++ b/src/zigUtil.ts @@ -1,20 +1,13 @@ import vscode from "vscode"; import childProcess from "child_process"; -import crypto from "crypto"; import fs from "fs"; import os from "os"; import path from "path"; -import { promisify } from "util"; -import assert from "assert"; -import axios from "axios"; import semver from "semver"; import which from "which"; -const execFile = promisify(childProcess.execFile); -const chmod = promisify(fs.chmod); - // Replace any references to predefined variables in config string. // https://code.visualstudio.com/docs/editor/variables-reference#_predefined-variables export function handleConfigOption(input: string): string { @@ -103,18 +96,37 @@ export async function shouldCheckUpdate(context: vscode.ExtensionContext, key: s return true; } +export function getZigArchName(): string { + switch (process.arch) { + case "ia32": + return "x86"; + case "x64": + return "x86_64"; + case "arm": + return "armv7a"; + case "arm64": + return "aarch64"; + case "ppc": + return "powerpc"; + case "ppc64": + return "powerpc64le"; + default: + return process.arch; + } +} +export function getZigOSName(): string { + switch (process.platform) { + case "darwin": + return "macos"; + case "win32": + return "windows"; + default: + return process.platform; + } +} + export function getHostZigName(): string { - let platform: string = process.platform; - if (platform === "darwin") platform = "macos"; - if (platform === "win32") platform = "windows"; - let arch: string = process.arch; - if (arch === "ia32") arch = "x86"; - if (arch === "x64") arch = "x86_64"; - if (arch === "arm") arch = "armv7a"; - if (arch === "arm64") arch = "aarch64"; - if (arch === "ppc") arch = "powerpc"; - if (arch === "ppc64") arch = "powerpc64le"; - return `${arch}-${platform}`; + return `${getZigArchName()}-${getZigOSName()}`; } export function getVersion(filePath: string, arg: string): semver.SemVer | null { @@ -130,90 +142,3 @@ export function getVersion(filePath: string, arg: string): semver.SemVer | null return null; } } - -export async function downloadAndExtractArtifact( - /** e.g. `Zig` or `ZLS` */ - title: string, - /** e.g. `zig` or `zls` */ - executableName: string, - /** e.g. inside `context.globalStorageUri` */ - installDir: vscode.Uri, - artifactUrl: string, - /** The expected sha256 hash (in hex) of the artifact/tarball. */ - sha256: string, - /** Extract arguments that should be passed to `tar`. e.g. `--strip-components=1` */ - extraTarArgs: string[], -): Promise { - assert.strictEqual(sha256.length, 64); - - return await vscode.window.withProgress( - { - title: `Installing ${title}`, - location: vscode.ProgressLocation.Notification, - }, - async (progress) => { - progress.report({ message: `downloading ${title} tarball...` }); - const response = await axios.get(artifactUrl, { - responseType: "arraybuffer", - onDownloadProgress: (progressEvent) => { - if (progressEvent.total) { - const increment = (progressEvent.bytes / progressEvent.total) * 100; - progress.report({ - message: progressEvent.progress - ? `downloading tarball ${(progressEvent.progress * 100).toFixed()}%` - : "downloading tarball...", - increment: increment, - }); - } - }, - }); - const tarHash = crypto.createHash("sha256").update(response.data).digest("hex"); - if (tarHash !== sha256) { - throw Error(`hash of downloaded tarball ${tarHash} does not match expected hash ${sha256}`); - } - - const tarPath = await which("tar", { nothrow: true }); - if (!tarPath) { - void vscode.window.showErrorMessage( - `Downloaded ${title} tarball can't be extracted because 'tar' could not be found`, - ); - return null; - } - - const tarballUri = vscode.Uri.joinPath(installDir, path.basename(artifactUrl)); - - try { - await vscode.workspace.fs.delete(installDir, { recursive: true, useTrash: false }); - } catch {} - await vscode.workspace.fs.createDirectory(installDir); - await vscode.workspace.fs.writeFile(tarballUri, response.data); - - progress.report({ message: "Extracting..." }); - try { - await execFile(tarPath, ["-xf", tarballUri.fsPath, "-C", installDir.fsPath].concat(extraTarArgs), { - timeout: 60000, // 60 seconds - }); - } catch (err) { - if (err instanceof Error) { - void vscode.window.showErrorMessage(`Failed to extract ${title} tarball: ${err.message}`); - } else { - throw err; - } - return null; - } finally { - try { - await vscode.workspace.fs.delete(tarballUri, { useTrash: false }); - } catch {} - } - - progress.report({ message: "Installing..." }); - - const isWindows = process.platform === "win32"; - const exeName = `${executableName}${isWindows ? ".exe" : ""}`; - const exePath = vscode.Uri.joinPath(installDir, exeName).fsPath; - await chmod(exePath, 0o755); - - return exePath; - }, - ); -} diff --git a/src/zls.ts b/src/zls.ts index 008f38e..d81f263 100644 --- a/src/zls.ts +++ b/src/zls.ts @@ -15,14 +15,15 @@ import axios from "axios"; import camelCase from "camelcase"; import semver from "semver"; -import { downloadAndExtractArtifact, getHostZigName, getVersion, getZigPath, handleConfigOption } from "./zigUtil"; -import { existsSync } from "fs"; +import { getHostZigName, getVersion, getZigPath, handleConfigOption } from "./zigUtil"; +import { VersionManager } from "./versionManager"; const ZIG_MODE: DocumentSelector = [ { language: "zig", scheme: "file" }, { language: "zig", scheme: "untitled" }, ]; +let versionManager: VersionManager; let outputChannel: vscode.OutputChannel; export let client: LanguageClient | null = null; @@ -104,25 +105,12 @@ async function getZLSPath(context: vscode.ExtensionContext): Promise<{ exe: stri const result = await fetchVersion(context, zigVersion, true); if (!result) return null; - const isWindows = process.platform === "win32"; - const installDir = vscode.Uri.joinPath(context.globalStorageUri, "zls", result.version.raw); - zlsExePath = vscode.Uri.joinPath(installDir, isWindows ? "zls.exe" : "zls").fsPath; - zlsVersion = result.version; - - if (!existsSync(zlsExePath)) { - try { - await downloadAndExtractArtifact( - "ZLS", - "zls", - installDir, - result.artifact.tarball, - result.artifact.shasum, - [], - ); - } catch { - void vscode.window.showErrorMessage(`Failed to install ZLS ${result.version.toString()}!`); - return null; - } + try { + zlsExePath = await versionManager.install(result.version); + zlsVersion = result.version; + } catch { + void vscode.window.showErrorMessage(`Failed to install ZLS ${result.version.toString()}!`); + return null; } } @@ -369,6 +357,8 @@ export async function activate(context: vscode.ExtensionContext) { } } + versionManager = new VersionManager(context, "zls"); + outputChannel = vscode.window.createOutputChannel("Zig Language Server"); context.subscriptions.push(